mountPortalTarget(node, "preview")}
- className={advancedFocusClasses.surfaceHostAdvanced}
- />
+
)}
{isManual && activeFocus && advancedRect && closePosition && (
@@ -357,10 +394,7 @@ const LoadedBoardItemContent = ({
contentRef={setManualSurface}
onClose={() => close()}
>
-
mountPortalTarget(node, "manual")}
- className={advancedFocusClasses.manualSurfaceMount}
- />
+
)}
@@ -400,7 +434,7 @@ interface InnerContentProps {
Component: ComponentType
>;
}
-const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
+const InnerContent = memo(function InnerContent({ item, definition, Component, ...dimensions }: InnerContentProps) {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
@@ -474,7 +508,7 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
)}
);
-};
+});
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
if (when) throw error;
diff --git a/apps/nextjs/src/components/board/layout/README.md b/apps/nextjs/src/components/board/layout/README.md
index 8a12bc180..12ac371d0 100644
--- a/apps/nextjs/src/components/board/layout/README.md
+++ b/apps/nextjs/src/components/board/layout/README.md
@@ -5,12 +5,14 @@ The board uses fixed logical units:
- one `1 × 1` item is exactly `200 × 200` logical pixels;
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
-- the canvas is transformed once with one uniform scale derived from its available width.
+- the canvas is zoomed once with one uniform scale derived from its available width.
Items keep the same logical dimensions at every viewport. The viewport reserves
the painted size while the inner canvas remains at its logical dimensions, so a
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
-stable.
+stable. The visual canvas always fits the available width, every root lane
+keeps at least one viewport of height, and edit-mode previews extend it downward
+as needed.
The read-only renderer should:
diff --git a/apps/nextjs/src/components/board/layout/constants.ts b/apps/nextjs/src/components/board/layout/constants.ts
index 9c3103b4b..26e3f0765 100644
--- a/apps/nextjs/src/components/board/layout/constants.ts
+++ b/apps/nextjs/src/components/board/layout/constants.ts
@@ -5,6 +5,5 @@
export const LOGICAL_GRID_CELL_SIZE = 200;
export const LOGICAL_GRID_GAP = 24;
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
-export const MIN_ACCESSIBLE_CANVAS_SCALE = 0.75;
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
diff --git a/apps/nextjs/src/components/board/layout/geometry.ts b/apps/nextjs/src/components/board/layout/geometry.ts
index 32b7fced4..449e74d4d 100644
--- a/apps/nextjs/src/components/board/layout/geometry.ts
+++ b/apps/nextjs/src/components/board/layout/geometry.ts
@@ -21,6 +21,13 @@ export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSPropert
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0);
+export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale: number) => {
+ if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
+ if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
+
+ return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH));
+};
+
export const normalizeGridPlacement = (
placement: TPlacement,
columnCount: number,
diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css
index 6c98e40a2..feb2a8ac5 100644
--- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css
+++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css
@@ -2,9 +2,7 @@
width: 100%;
max-width: 100%;
min-width: 0;
- overflow: visible;
- overflow-x: auto;
- overscroll-behavior-inline: contain;
+ overflow: clip;
}
.viewport[data-board-hydrated="false"] {
@@ -18,8 +16,6 @@
.canvas {
position: absolute;
inset: 0 auto auto 0;
- transform-origin: top left;
- will-change: transform;
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
@@ -44,9 +40,3 @@
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
}
-
-@media (prefers-reduced-motion: reduce) {
- .canvas {
- will-change: auto;
- }
-}
diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx
index 3d5de2b72..b11356b93 100644
--- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx
+++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx
@@ -5,11 +5,8 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { Box } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
-import { MIN_ACCESSIBLE_CANVAS_SCALE } from "./constants";
import classes from "./scaled-board-canvas.module.css";
-export { MIN_ACCESSIBLE_CANVAS_SCALE };
-
const BoardCanvasScaleContext = createContext(1);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -18,7 +15,7 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
- return Math.max(MIN_ACCESSIBLE_CANVAS_SCALE, availableWidth / logicalWidth);
+ return availableWidth / logicalWidth;
};
export const calculateBoardUiScale = (canvasScale: number) => {
@@ -36,9 +33,8 @@ interface ScaledBoardCanvasProps {
/**
* Scales the complete board as one surface while keeping widget layout in
- * fixed logical pixels. The scale floor deliberately allows horizontal
- * overflow so browser zoom still magnifies content instead of being cancelled
- * by responsive downscaling.
+ * fixed logical pixels. The complete canvas always fits its viewport so board
+ * content can never widen the document or require horizontal scrolling.
*/
export const ScaledBoardCanvas = ({
logicalWidth,
@@ -62,6 +58,7 @@ export const ScaledBoardCanvas = ({
[logicalWidth, resolvedAvailableWidth],
);
const uiScale = calculateBoardUiScale(scale);
+ const inverseScale = scale > 0 ? 1 / scale : 1;
const visualWidth = logicalWidth * scale;
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
@@ -84,8 +81,9 @@ export const ScaledBoardCanvas = ({
className={classes.canvas}
w={logicalWidth}
style={{
+ "--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
- transform: `scale(${scale})`,
+ zoom: scale,
}}
>
{children}
diff --git a/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts b/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts
index ecb537089..80ec9c8c3 100644
--- a/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts
+++ b/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts
@@ -16,6 +16,7 @@ describe("board lanes", () => {
leftGutterColumnCount: 2,
rightGutterColumnCount: 3,
breakpoint: 0,
+ role: "base",
} satisfies Board["layouts"][number];
expect(getBoardLaneColumnCount(layout, "left")).toBe(2);
@@ -31,6 +32,7 @@ describe("board lanes", () => {
leftGutterColumnCount: 3,
rightGutterColumnCount: 3,
breakpoint: 0,
+ role: "base",
} satisfies Board["layouts"][number];
expect(getBoardLaneColumnCount(layout, "left")).toBe(3);
@@ -104,6 +106,7 @@ describe("board lanes", () => {
leftGutterColumnCount: 1,
rightGutterColumnCount: 0,
breakpoint: 0,
+ role: "base",
},
];
diff --git a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts
index 66f084703..3d46398bb 100644
--- a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts
+++ b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts
@@ -1,6 +1,12 @@
import { describe, expect, test } from "vitest";
-import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, normalizeGridPlacement } from "../index";
+import {
+ getGridRowCountForVisualHeight,
+ getLayoutRowCount,
+ getLogicalItemStyle,
+ getLogicalTrackSize,
+ normalizeGridPlacement,
+} from "../index";
describe("fixed dashboard geometry", () => {
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
@@ -24,4 +30,12 @@ describe("fixed dashboard geometry", () => {
expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 });
expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7);
});
+
+ test("derives enough logical rows to fill the visual viewport at any canvas scale", () => {
+ expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
+ expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
+
+ expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11);
+ expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
+ });
});
diff --git a/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts b/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts
index 83e14ea02..50fae3420 100644
--- a/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts
+++ b/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts
@@ -1,9 +1,9 @@
import { describe, expect, test } from "vitest";
-import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas";
+import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas";
describe("board canvas scale policy", () => {
- test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => {
+ test("fits the logical canvas exactly", () => {
const scale = calculateBoardCanvasScale(840, 1000);
expect(scale).toBe(0.84);
@@ -14,16 +14,16 @@ describe("board canvas scale policy", () => {
expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8);
});
- test("stops counter-scaling at 0.75 and lets the canvas overflow", () => {
+ test("keeps fitting narrow viewports without horizontal overflow", () => {
const availableWidth = 600;
const logicalWidth = 1000;
const scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
- expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
- expect(logicalWidth * scale).toBeGreaterThan(availableWidth);
+ expect(scale).toBe(0.6);
+ expect(logicalWidth * scale).toBe(availableWidth);
});
- test("keeps content materially magnified at 200% effective zoom", () => {
+ test("keeps the board fitted at 200% effective zoom", () => {
const logicalWidth = 1696;
const normalAvailableWidth = 1872;
const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth);
@@ -31,8 +31,8 @@ describe("board canvas scale policy", () => {
const normalPaintedCellSize = 200 * normalScale;
const zoomedPaintedCellSize = 200 * zoomedScale * 2;
- expect(zoomedScale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
- expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25);
+ expect(zoomedScale).toBeCloseTo(normalScale / 2);
+ expect(zoomedPaintedCellSize).toBeCloseTo(normalPaintedCellSize);
});
test.each([
diff --git a/apps/nextjs/src/components/board/sections/empty-section.tsx b/apps/nextjs/src/components/board/sections/empty-section.tsx
index 5e4ef1d65..ab958e75f 100644
--- a/apps/nextjs/src/components/board/sections/empty-section.tsx
+++ b/apps/nextjs/src/components/board/sections/empty-section.tsx
@@ -18,7 +18,7 @@ export const BoardEmptySection = ({ section, columnCount, requestedRowCount, rai
const [isEditMode] = useEditMode();
const t = useScopedI18n("board.landmark");
- if (totalLength === 0 && !isEditMode && requestedRowCount === 0) return null;
+ if (totalLength === 0 && !isEditMode && requestedRowCount === 0 && railPlacement !== "main") return null;
return (
.board-grid-content-mount {
+ overflow: clip;
+}
+
.board-grid-placeholder {
z-index: 0;
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
@@ -178,7 +182,7 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
.board-grid-resize-handle[data-grid-resize-handle="s"] {
right: 44px;
left: 44px;
- height: 32px;
+ height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
cursor: ns-resize;
}
@@ -194,7 +198,7 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
.board-grid-resize-handle[data-grid-resize-handle="w"] {
top: 44px;
bottom: 44px;
- width: 32px;
+ width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
cursor: ew-resize;
}
@@ -217,8 +221,8 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
.board-grid-resize-handle[data-grid-resize-handle="sw"],
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
z-index: 9;
- width: 44px;
- height: 44px;
+ width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
+ height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
}
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
index 192cbf7f9..d4d0782ab 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
@@ -10,6 +10,7 @@ import { CollisionPriority } from "@dnd-kit/abstract";
import { pointerIntersection } from "@dnd-kit/collision";
import type { DragDropManager, Draggable as DomDraggable } from "@dnd-kit/dom";
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
+import { RestrictToElement } from "@dnd-kit/dom/modifiers";
import { getEventCoordinates, getFrameTransform, isPointerEvent } from "@dnd-kit/dom/utilities";
import type {
BeforeDragStartEvent,
@@ -720,6 +721,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
if (!previewGrid || !interaction) return placements;
if (interaction.sourceGridId === sectionId) {
+ if (interaction.mode === "resize") return previewGrid.placements;
const original = controlledById.get(interaction.activeId);
if (!original) return previewGrid.placements;
return previewGrid.placements.some((placement) => placement.id === interaction.activeId)
@@ -734,9 +736,16 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
);
const targetPlacement =
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
+ const growsDuringDrag =
+ interaction?.mode === "drag" &&
+ (interaction.targetGridId === sectionId ||
+ (interaction.targetGridId === null && interaction.sourceGridId === sectionId));
+ const participatesInInteraction =
+ interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId);
+ const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements);
const renderedRowCount =
- section.kind !== "container" && interaction?.mode === "resize"
- ? Math.max(rowCount, getLayoutRowCount(renderPlacements))
+ section.kind !== "container" && participatesInInteraction
+ ? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0)
: rowCount;
const { ref: droppableRef, isDropTarget } = useDroppable({
@@ -876,6 +885,14 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
],
[],
);
+ const modifiers = useMemo(
+ () => [
+ RestrictToElement.configure({
+ element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null,
+ }),
+ ],
+ [],
+ );
const {
ref: draggableRef,
handleRef,
@@ -886,6 +903,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
type: GRID_ENTRY_TYPE,
data: sourceData,
sensors,
+ modifiers,
});
const setShellRef = useCallback(
(element: HTMLDivElement | null) => {
diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx
index 3ab7b292b..ab1b1fb4b 100644
--- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo } from "react";
+import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { Box, LoadingOverlay } from "@mantine/core";
import combineClasses from "clsx";
@@ -13,12 +13,14 @@ import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types
import {
getCollapsedDisplayLayout,
getEditableCanvasAttributes,
+ getGridRowCountForVisualHeight,
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
+import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { loadGridEditorAsync } from "./grid-editor-loader";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -81,6 +83,7 @@ export const SectionGrid = ({
className,
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
+ const canvasScale = useBoardCanvasScale();
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
@@ -144,8 +147,9 @@ export const SectionGrid = ({
);
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id)));
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
+ const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
- const rowCount = Math.max(contentRowCount, requestedRowCount);
+ const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const canvasAttributes = isEditMode
@@ -199,6 +203,24 @@ export const SectionGrid = ({
);
};
+const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
+ const [visualHeight, setVisualHeight] = useState(0);
+
+ useEffect(() => {
+ if (!enabled) return;
+ const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight);
+ update();
+ window.addEventListener("resize", update, { passive: true });
+ window.visualViewport?.addEventListener("resize", update, { passive: true });
+ return () => {
+ window.removeEventListener("resize", update);
+ window.visualViewport?.removeEventListener("resize", update);
+ };
+ }, [enabled]);
+
+ return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
+};
+
const getContainerMinimumSize = (
board: ReturnType,
layoutId: string,
diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts
index 8491dfc17..6da6a4f2a 100644
--- a/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts
+++ b/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts
@@ -27,6 +27,7 @@ describe("board grid import boundary", () => {
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/abstract" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/collision" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom" },
+ { file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/modifiers" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/utilities" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/react" },
]);
diff --git a/apps/nextjs/src/components/board/sections/section-actions.spec.ts b/apps/nextjs/src/components/board/sections/section-actions.spec.ts
new file mode 100644
index 000000000..441e71ef7
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/section-actions.spec.ts
@@ -0,0 +1,42 @@
+import { describe, expect, test } from "vitest";
+
+import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
+import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
+import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
+import { wouldCreateSectionCycle } from "./section-actions";
+
+describe("wouldCreateSectionCycle", () => {
+ test("rejects moving a container into itself or one of its descendants", () => {
+ const layoutId = "layout";
+ const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
+ const parentSection = new ContainerSectionMockBuilder({ id: "parent" })
+ .addLayout({ layoutId, parentSectionId: rootSection.id })
+ .build();
+ const childSection = new ContainerSectionMockBuilder({ id: "child" })
+ .addLayout({ layoutId, parentSectionId: parentSection.id })
+ .build();
+ const board = new BoardMockBuilder().addSections([rootSection, parentSection, childSection]).build();
+
+ expect(
+ wouldCreateSectionCycle(board, {
+ innerSectionId: parentSection.id,
+ sectionId: parentSection.id,
+ layoutId,
+ }),
+ ).toBe(true);
+ expect(
+ wouldCreateSectionCycle(board, {
+ innerSectionId: parentSection.id,
+ sectionId: childSection.id,
+ layoutId,
+ }),
+ ).toBe(true);
+ expect(
+ wouldCreateSectionCycle(board, {
+ innerSectionId: childSection.id,
+ sectionId: rootSection.id,
+ layoutId,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/apps/nextjs/src/components/board/sections/section-actions.tsx b/apps/nextjs/src/components/board/sections/section-actions.tsx
index a978e712f..47813fc16 100644
--- a/apps/nextjs/src/components/board/sections/section-actions.tsx
+++ b/apps/nextjs/src/components/board/sections/section-actions.tsx
@@ -1,8 +1,10 @@
import { useCallback } from "react";
-import { getCurrentLayout } from "@homarr/boards/context";
+import { useCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
+import type { Board } from "~/app/[locale]/boards/_types";
+
interface MoveAndResizeInnerSection {
innerSectionId: string;
xOffset: number;
@@ -21,6 +23,7 @@ interface MoveInnerSectionToSection {
export const useSectionActions = () => {
const { updateBoard } = useUpdateBoard();
+ const currentLayoutId = useCurrentLayout();
const moveAndResizeInnerSection = useCallback(
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
@@ -31,12 +34,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "container") return section;
- const currentLayout = getCurrentLayout(previous);
-
return {
...section,
layouts: section.layouts.map((layout) => {
- if (layout.layoutId !== currentLayout) return layout;
+ if (layout.layoutId !== currentLayoutId) return layout;
return {
...layout,
...positionProps,
@@ -46,12 +47,16 @@ export const useSectionActions = () => {
}),
}));
},
- [updateBoard],
+ [currentLayoutId, updateBoard],
);
const moveInnerSectionToSection = useCallback(
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
updateBoard((previous) => {
+ if (wouldCreateSectionCycle(previous, { innerSectionId, sectionId, layoutId: currentLayoutId })) {
+ return previous;
+ }
+
return {
...previous,
sections: previous.sections.map((section) => {
@@ -59,12 +64,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "container") return section;
- const currentLayout = getCurrentLayout(previous);
-
return {
...section,
layouts: section.layouts.map((layout) => {
- if (layout.layoutId !== currentLayout) return layout;
+ if (layout.layoutId !== currentLayoutId) return layout;
return {
...layout,
...positionProps,
@@ -76,7 +79,7 @@ export const useSectionActions = () => {
};
});
},
- [updateBoard],
+ [currentLayoutId, updateBoard],
);
return {
@@ -84,3 +87,24 @@ export const useSectionActions = () => {
moveInnerSectionToSection,
};
};
+
+export const wouldCreateSectionCycle = (
+ board: Board,
+ { innerSectionId, sectionId, layoutId }: { innerSectionId: string; sectionId: string; layoutId: string },
+) => {
+ const visitedSectionIds = new Set();
+ let currentSectionId: string | null = sectionId;
+
+ while (currentSectionId) {
+ if (currentSectionId === innerSectionId) return true;
+ if (visitedSectionIds.has(currentSectionId)) return true;
+ visitedSectionIds.add(currentSectionId);
+
+ const currentSection = board.sections.find((section) => section.id === currentSectionId);
+ if (currentSection?.kind !== "container") return false;
+
+ currentSectionId = currentSection.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
+ }
+
+ return false;
+};
diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx
index 2cfed0b5b..4b01f96ca 100644
--- a/apps/nextjs/src/components/user-avatar-menu.tsx
+++ b/apps/nextjs/src/components/user-avatar-menu.tsx
@@ -2,16 +2,26 @@
import type { ReactNode } from "react";
import { useCallback } from "react";
-import { Menu } from "@mantine/core";
-import { IconBrandDocker, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
+import { Badge, Indicator, Kbd, Loader, Menu, Text } from "@mantine/core";
+import {
+ IconBrandDocker,
+ IconHome,
+ IconLogin,
+ IconLogout,
+ IconSettings,
+ IconRobot,
+ IconTool,
+} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
+import { hotkeys } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
+import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
@@ -25,6 +35,12 @@ interface UserAvatarMenuProps {
onOpenChange: (opened: boolean) => void;
}
+const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
+ hotkey
+ .split("+")
+ .map((key) => (key === "mod" ? modifierLabel : `${key.charAt(0).toUpperCase()}${key.slice(1)}`))
+ .join(" + ");
+
export const UserAvatarMenu = ({
children,
availableUpdates,
@@ -37,6 +53,7 @@ export const UserAvatarMenu = ({
const { logoutUrl } = useAuthContext();
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
+ const assistant = useOptionalHomarrAssistant();
const handleSignout = useCallback(async () => {
const redirectUrl = logoutUrl ?? "/auth/login";
@@ -65,6 +82,29 @@ export const UserAvatarMenu = ({
{Boolean(session.data) && (
<>
+ {assistant?.enabled && (
+ :
+ }
+ rightSection={
+ assistant.unreadCount > 0 ? (
+
+ {assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
+
+ ) : assistant.isRunning ? (
+
+ {t("assistantThinking")}
+
+ ) : (
+ {formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}
+ )
+ }
+ onClick={assistant.open}
+ >
+ {t("assistant")}
+
+ )}
)}
- {children}
+
+
+ ) : assistant?.unreadCount ? (
+ assistant.unreadCount > 99 ? (
+ "99+"
+ ) : (
+ assistant.unreadCount
+ )
+ ) : undefined
+ }
+ >
+ {children}
+
+
);
};
diff --git a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx
index 89ab01c59..0c79f728f 100644
--- a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx
+++ b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx
@@ -4,6 +4,8 @@ import { useState } from "react";
import { Button, Modal } from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
+import { modalSizeSelect } from "@homarr/modals";
+
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
@@ -14,7 +16,7 @@ export function WorkshopCssImportButton({ onImport }: { onImport(css: string): v
} onClick={() => setOpened(true)}>
Import from Workshop
- setOpened(false)} title="Import Custom CSS" size="xl">
+ setOpened(false)} title="Import Custom CSS" size={modalSizeSelect}>
{
diff --git a/e2e/assistant-management.spec.ts b/e2e/assistant-management.spec.ts
new file mode 100644
index 000000000..df2c00f61
--- /dev/null
+++ b/e2e/assistant-management.spec.ts
@@ -0,0 +1,98 @@
+import { chromium, expect } from "@playwright/test";
+import { describe, test } from "vitest";
+
+import { createHomarrContainer } from "./shared/create-homarr-container";
+import { createSqliteDbFileAsync } from "./shared/e2e-db";
+import { seedAdminUserAsync } from "./shared/seed-admin-user";
+import * as sqliteSchema from "../packages/db/schema/sqlite";
+
+const adminCredentials = {
+ username: "admin",
+ password: "Comp(exP4sswOrd",
+};
+
+describe("Assistant management", () => {
+ test("keeps provider actions clear and the API key replacement flow responsive", async () => {
+ const { db, localMountPath } = await createSqliteDbFileAsync();
+ await seedAdminUserAsync(db, adminCredentials);
+ await db.insert(sqliteSchema.assistantConfigurations).values({
+ id: "default",
+ enabled: true,
+ provider: "openrouter",
+ baseUrl: "https://openrouter.ai/api/v1",
+ modelDiscoveryPath: null,
+ encryptedApiKey: "encrypted.key",
+ modelId: "deepseek/deepseek-v4-flash",
+ });
+
+ const homarrContainer = await createHomarrContainer({
+ environment: {
+ AUTH_PROVIDERS: "credentials",
+ },
+ mounts: {
+ "/appdata": localMountPath,
+ },
+ }).start();
+
+ const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
+ const browser = await chromium.launch();
+ const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
+ const page = await context.newPage();
+
+ try {
+ await page.goto(`${baseUrl}/auth/login`);
+ await page.getByLabel("Username").fill(adminCredentials.username);
+ await page.locator("#password").fill(adminCredentials.password);
+ await page.getByRole("button", { name: "Login" }).click();
+ await expect(page).not.toHaveURL(/\/auth\/login/, { timeout: 30_000 });
+
+ await page.goto(`${baseUrl}/manage/assistant`);
+ await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible();
+ await expect(page.getByText("Connection ready")).toBeVisible();
+ await expect(page.getByText("Key protected")).toBeVisible();
+ await expect(page.getByText("Encrypted API key saved")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Remove saved API key" })).toBeVisible();
+
+ await page.getByRole("button", { name: "Replace key" }).click();
+ const newKey = page.getByLabel("New API key");
+ await expect(newKey).toBeFocused();
+ await newKey.fill("replacement-test-api-key");
+ await page.getByRole("button", { name: "Review replacement" }).click();
+ await expect(page.getByText("The current key will stop being used")).toBeVisible();
+ await expect(page.getByText("https://openrouter.ai/api/v1", { exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Replace API key" })).toBeVisible();
+
+ await page.getByRole("button", { name: "Edit new key" }).click();
+ await page.getByRole("button", { name: "Cancel replacement" }).click();
+ await page.getByRole("button", { name: "Additional request headers" }).click();
+ await expect(page.getByRole("button", { name: "Add header" })).toBeVisible();
+
+ await page.getByRole("button", { name: "Remove all credentials" }).click();
+ await expect(page.getByRole("dialog", { name: "Remove all assistant credentials" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Remove credentials" })).toBeVisible();
+ await page.getByRole("button", { name: "Keep credentials" }).click();
+ await expect(page.getByRole("dialog", { name: "Remove all assistant credentials" })).toBeHidden();
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.reload();
+ await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Replace key" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Save assistant" })).toBeVisible();
+ await page.getByRole("button", { name: "Replace key" }).click();
+ await expect(page.getByLabel("New API key")).toBeFocused();
+ await page.getByLabel("New API key").fill("replacement-test-api-key");
+ await page.getByRole("button", { name: "Review replacement" }).click();
+ await expect(page.getByText("The current key will stop being used")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Edit new key" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Replace API key" })).toBeVisible();
+ const hasHorizontalOverflow = await page.evaluate(
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth,
+ );
+ expect(hasHorizontalOverflow).toBe(false);
+ } finally {
+ await browser.close();
+ await homarrContainer.stop();
+ }
+ }, 120_000);
+});
diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts
index 9b2448b91..b970eec28 100644
--- a/e2e/board-advanced-interactions.spec.ts
+++ b/e2e/board-advanced-interactions.spec.ts
@@ -130,7 +130,11 @@ describe("Board advanced interactions", () => {
await interactivePreviewProbe.evaluate((element) => element.remove());
await page.mouse.move(compactBounds.x + 4, compactBounds.y + 4);
await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2);
+ await expect(previewSurface).toBeVisible();
+ await expect(bookmarksPreviewSurface).toBeHidden();
+ await page.mouse.move(4, 4);
await expect(previewSurface).toBeHidden();
+ await otherWidget.hover({ position: { x: 4, y: 4 } });
await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 });
await page.keyboard.up("Shift");
await expect(bookmarksPreviewSurface).toBeHidden();
diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts
index 47d734263..1be6bdb9b 100644
--- a/e2e/board-grid.spec.ts
+++ b/e2e/board-grid.spec.ts
@@ -6,11 +6,7 @@ import { createId } from "@paralleldrive/cuid2";
import { stringify as stringifySuperJSON } from "superjson";
import { describe, test } from "vitest";
-import {
- LOGICAL_GRID_CELL_SIZE,
- LOGICAL_GRID_GAP,
- MIN_ACCESSIBLE_CANVAS_SCALE,
-} from "../apps/nextjs/src/components/board/layout/constants";
+import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP } from "../apps/nextjs/src/components/board/layout/constants";
import * as sqliteSchema from "../packages/db/schema/sqlite";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
@@ -26,7 +22,6 @@ const gridRuntimeMarker = "homarr-dnd-kit-v1";
const dndKitPayloadMarker = "dnd-kit-description";
const logicalCellSize = LOGICAL_GRID_CELL_SIZE;
const logicalCellPitch = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
-const minimumCanvasScale = MIN_ACCESSIBLE_CANVAS_SCALE;
describe("Board grid", () => {
test("supports fixed, nested, rail, pointer, keyboard and reconciliation workflows", async () => {
@@ -100,6 +95,11 @@ describe("Board grid", () => {
await expect(rail).toBeVisible();
await expect(rail).toHaveAttribute("data-board-gutter", "left");
await expect(railRootSection).toHaveAttribute("data-rail-placement", "left");
+ await expect(rail).toHaveCSS("overflow-x", "hidden");
+ const initialRailBox = await expectBoundingBoxAsync(rail);
+ const initialRailRootBox = await expectBoundingBoxAsync(railRootSection);
+ expect(initialRailRootBox.width).toBeCloseTo(initialRailBox.width, 1);
+ expect(initialRailRootBox.height).toBeGreaterThanOrEqual(initialRailBox.height - 1);
await expect(railItem).toHaveAttribute("data-grid-x", "0");
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
await expect(containerSection).toHaveAttribute("data-grid-h", "2");
@@ -174,21 +174,15 @@ describe("Board grid", () => {
expect(await readLayoutShiftScoreAsync(page)).toBeLessThan(0.01);
// CSS zoom exercises Chromium's browser-zoom layout path without relying
- // on desktop browser chrome. At 200% the scale floor must preserve
- // magnification and expose horizontal scrolling instead of shrinking the
- // board by the inverse zoom factor.
+ // on desktop browser chrome. The board must keep fitting without adding
+ // horizontal scrolling at any effective zoom.
await setDocumentZoomAsync(page, 2);
- await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale));
- await expect(canvas).toHaveAttribute("data-canvas-overflow", "true");
+ const zoomedCanvasScale = await readCanvasScaleAsync(canvas);
+ expect(zoomedCanvasScale).toBeLessThan(normalCanvasScale);
+ await expect(canvas).toHaveAttribute("data-canvas-overflow", "false");
await expectFixedLogicalTileAsync(logicalTile);
await expectUniformVisualScaleAsync(logicalTile);
- const fittedClockFontSize = await firstItem.locator(".clock-time-text").evaluate((element) => {
- return Number.parseFloat(getComputedStyle(element).fontSize);
- });
- expect(fittedClockFontSize * minimumCanvasScale).toBeGreaterThanOrEqual(25);
- const zoomedTileBox = await expectBoundingBoxAsync(logicalTile);
- expect(zoomedTileBox.width).toBeGreaterThan(normalZoomTileBox.width * 1.25);
- await expectHorizontalOverflowAsync(canvas);
+ await expect(canvas).toHaveCSS("overflow-x", "clip");
await expectDocumentNotHorizontallyScrollableAsync(page);
const overflowRailY = (await expectBoundingBoxAsync(rail)).y;
await page.evaluate(() => window.scrollTo({ top: 300, behavior: "auto" }));
@@ -251,6 +245,14 @@ describe("Board grid", () => {
await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3);
await expect(page.getByTestId("board-grid-editor-loading")).toHaveCount(0);
await expect(page.getByTestId("board-canvas-row-count-button")).toHaveCount(0);
+ await expect(rail).toHaveAttribute("data-board-editing", "true");
+ await expect(rail).toHaveCSS("position", "sticky");
+ await expect(rail).toHaveCSS("transform", "none");
+ const railBox = await expectBoundingBoxAsync(rail);
+ expect(railBox.height + railBox.y).toBeCloseTo(page.viewportSize()?.height ?? 0, 0);
+ expect(await rail.evaluate((element) => getComputedStyle(element, "::before").content.replaceAll('"', ""))).toBe(
+ "Left dashboard rail",
+ );
await expect(logicalTile).toHaveCSS("overflow-x", "hidden");
await expect(logicalTile).toHaveCSS("overflow-y", "auto");
await logicalTile.evaluate((element) => {
@@ -337,8 +339,11 @@ describe("Board grid", () => {
await expect(itemSelectDialog).toHaveCount(0);
const mainGrid = page.locator(`[data-grid-section-id="${fixture.sectionId}"]`);
+ const editorViewport = page.viewportSize();
+ if (!editorViewport) throw new Error("Expected a browser viewport");
+ expect((await expectBoundingBoxAsync(mainGrid)).height).toBeGreaterThanOrEqual(editorViewport.height - 1);
const interactionSnapshot = await readEditorGridSnapshotAsync(mainGrid);
- await expectGridBoundaryRejectsOverflowAsync(page, mainGrid, firstItem);
+ await expectGridGrowsAtBoundaryAsync(page, canvas, mainGrid, firstItem);
await firstItem.locator("[data-grid-item-content]").evaluate((element) => {
const iframe = document.createElement("iframe");
iframe.dataset.e2eDragIframe = "true";
@@ -367,8 +372,8 @@ describe("Board grid", () => {
.evaluateAll((elements) => elements.forEach((element) => element.remove()));
await page.setViewportSize({ width: 600, height: 1200 });
- await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale));
- await expectHorizontalOverflowAsync(canvas);
+ await expect(canvas).toHaveAttribute("data-canvas-overflow", "false");
+ await expect(canvas).toHaveCSS("overflow-x", "clip");
await expectDocumentNotHorizontallyScrollableAsync(page);
for (const direction of ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const) {
await expectTargetSizeAsync(firstItem.locator(`:scope > [data-grid-resize-handle="${direction}"]`));
@@ -718,13 +723,12 @@ describe("Board grid", () => {
expect(continuousResizeWidth).toBeGreaterThan(logicalCellSize * canvasScale);
await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale, eastResizeStart.y, { steps: 6 });
- await expect(containerSection).toHaveAttribute("data-grid-w", "2");
+ await expect(containerSection).toHaveAttribute("data-grid-w", "1");
await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "1");
expect((await expectBoundingBoxAsync(resizeOutline)).width).toBeCloseTo(logicalCellSize * canvasScale, 1);
- const liveNestedGridBox = await expectBoundingBoxAsync(nestedEditor);
const liveContainerBox = await expectBoundingBoxAsync(containerSection);
- expect(liveNestedGridBox.width).toBeCloseTo(originalResizeWidth, 1);
- expect(liveNestedGridBox.width).toBeLessThanOrEqual(liveContainerBox.width + 1);
+ expect(liveContainerBox.width).toBeCloseTo(logicalCellSize * canvasScale, 1);
+ await expect(containerSection.locator(":scope > .board-grid-content-mount")).toHaveCSS("overflow", "clip");
await page.mouse.up();
await expect(containerSection).toHaveAttribute("data-grid-w", "1");
await expect(resizeOutline).toHaveCount(0);
@@ -759,15 +763,26 @@ describe("Board grid", () => {
await expect(containerSection).toHaveAttribute("data-grid-h", "2");
const southResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="s"]');
- await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale);
+ const gridHeightBeforeSouthResize = (await expectBoundingBoxAsync(mainGrid)).height;
+ const southResizeBox = await expectBoundingBoxAsync(southResizeHandle);
+ const southResizeStart = {
+ x: southResizeBox.x + southResizeBox.width / 2,
+ y: southResizeBox.y + southResizeBox.height / 2,
+ };
+ await page.mouse.move(southResizeStart.x, southResizeStart.y);
+ await page.mouse.down();
+ await page.mouse.move(southResizeStart.x, southResizeStart.y + logicalCellPitch * canvasScale, { steps: 8 });
await expect(containerSection).toHaveAttribute("data-grid-h", "3");
await expect(belowItem).toHaveAttribute("data-grid-y", "3");
+ await expect
+ .poll(async () => (await expectBoundingBoxAsync(mainGrid)).height)
+ .toBeGreaterThanOrEqual(gridHeightBeforeSouthResize);
+ await page.mouse.up();
expect(
gridLayoutsOverlap(await readGridLayoutAsync(containerSection), await readGridLayoutAsync(belowItem)),
).toBe(false);
- // Auto-scroll needs real document overflow. Grow the canvas through an
- // explicit resize instead of relying on edit mode to add empty rows.
+ // Grow the canvas further to exercise sustained document auto-scroll.
await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale * 6);
await expect(containerSection).toHaveAttribute("data-grid-h", "9");
await expect(belowItem).toHaveAttribute("data-grid-y", "9");
@@ -843,14 +858,10 @@ describe("Board grid", () => {
false,
);
- const stickyRailY = (await expectBoundingBoxAsync(rail)).y;
await page.evaluate(() => window.scrollTo({ top: 500, behavior: "auto" }));
- await expect
- .poll(async () => {
- const y = (await expectBoundingBoxAsync(rail)).y;
- return y >= 0 && y <= stickyRailY + 0.5;
- })
- .toBe(true);
+ const stickyRailY = (await expectBoundingBoxAsync(rail)).y;
+ await page.evaluate(() => window.scrollTo({ top: 600, behavior: "auto" }));
+ await expect.poll(async () => (await expectBoundingBoxAsync(rail)).y).toBeCloseTo(stickyRailY, 0);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" }));
await setDocumentZoomAsync(page, 1.25);
@@ -979,6 +990,15 @@ describe("Board grid", () => {
const emptyRightGutter = page.getByRole("complementary", { name: "Right dashboard rail" });
await expect(emptyRightGutter).toHaveCount(1);
expect((await expectBoundingBoxAsync(emptyRightGutter)).width).toBeGreaterThan(0);
+ await page.getByTestId("board-edit-mode-toggle").click();
+ await expect(emptyRightGutter).toHaveAttribute("data-board-editing", "true");
+ expect(
+ await emptyRightGutter.evaluate((element) => ({
+ content: getComputedStyle(element, "::before").content.replaceAll('"', ""),
+ insetInlineStart: getComputedStyle(element, "::before").insetInlineStart,
+ top: getComputedStyle(element, "::before").top,
+ })),
+ ).toEqual({ content: "Right dashboard rail", insetInlineStart: "0px", top: "0px" });
expect(pageErrors).toEqual([]);
} finally {
runtimeResources?.stop();
@@ -1713,9 +1733,10 @@ const expectImmediateOutsideReleaseRestoresSnapshotAsync = async (page: Page, gr
await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot);
};
-const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator, locator: Locator) => {
+const expectGridGrowsAtBoundaryAsync = async (page: Page, canvas: Locator, grid: Locator, locator: Locator) => {
const snapshot = await readEditorGridSnapshotAsync(grid);
const gridBox = await expectBoundingBoxAsync(grid);
+ const canvasBox = await expectBoundingBoxAsync(canvas);
const itemBox = await expectBoundingBoxAsync(locator);
const start = { x: itemBox.x + itemBox.width * 0.63, y: itemBox.y + itemBox.height * 0.42 };
@@ -1723,14 +1744,25 @@ const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator,
await page.mouse.down();
await page.mouse.move(start.x + 8, start.y);
await expect(locator).toHaveAttribute("data-dnd-drag-source", "true");
- await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch, { steps: 12 });
- await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(0);
- const activeGridBox = await expectBoundingBoxAsync(grid);
- expect(activeGridBox.height).toBeCloseTo(gridBox.height, 1);
+ await expect.poll(async () => (await expectBoundingBoxAsync(grid)).height).toBeGreaterThan(gridBox.height);
+ await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch / 2, {
+ steps: 12,
+ });
+ await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(1);
+ const activeGridBox = await expectBoundingBoxAsync(grid);
+ expect(activeGridBox.height).toBeGreaterThan(gridBox.height);
+
+ await page.mouse.move(canvasBox.x + canvasBox.width + 1000, start.y, { steps: 12 });
+ const overlayBox = await expectBoundingBoxAsync(page.locator(".board-grid-drag-overlay[data-dnd-dragging]"));
+ expect(overlayBox.x + overlayBox.width).toBeLessThanOrEqual(canvasBox.x + canvasBox.width + 1);
+
+ await page.keyboard.press("Escape");
await page.mouse.up();
await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting");
await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot);
+ await locator.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" }));
+ await expectGridEntryGeometrySettledAsync(locator);
};
const expectInvalidNestedDropRestoresSnapshotAsync = async (
@@ -2036,18 +2068,6 @@ const expectUniformVisualScaleAsync = async (locator: Locator) => {
expect(scale.horizontal).toBeCloseTo(scale.vertical, 3);
};
-const expectHorizontalOverflowAsync = async (locator: Locator) => {
- const dimensions = await locator.evaluate((element) => {
- const htmlElement = element as HTMLElement;
- return {
- clientWidth: htmlElement.clientWidth,
- scrollWidth: htmlElement.scrollWidth,
- };
- });
-
- expect(dimensions.scrollWidth).toBeGreaterThan(dimensions.clientWidth);
-};
-
const expectDocumentNotHorizontallyScrollableAsync = async (page: Page) => {
await expect
.poll(
diff --git a/package.json b/package.json
index 6857153a0..c9a60d7e4 100644
--- a/package.json
+++ b/package.json
@@ -46,8 +46,8 @@
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"start": "pnpm -F nextjs start",
- "test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --coverage.enabled ",
- "test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
+ "test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --retry 2 --coverage.enabled ",
+ "test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e --retry 2 --maxWorkers 2",
"test:workshop": "sh apps/workshop/test.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
"typecheck": "turbo typecheck",
diff --git a/packages/api/package.json b/packages/api/package.json
index 9642d9b99..15beaccc6 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -17,6 +17,8 @@
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
"./open-api": "./src/open-api.ts",
+ "./assistant": "./src/assistant.ts",
+ "./custom-widget/display-data": "./src/router/custom-widget/display-data.ts",
"./mcp": "./src/mcp.ts",
"./custom-widget-configuration": "./src/custom-widget-configuration.ts"
},
@@ -59,7 +61,6 @@
"react": "catalog:",
"react-dom": "catalog:",
"superjson": "catalog:",
- "trpc-to-mcp": "catalog:",
"trpc-to-openapi": "catalog:",
"undici": "catalog:",
"zod": "catalog:"
diff --git a/packages/api/src/assistant-generation-access.ts b/packages/api/src/assistant-generation-access.ts
new file mode 100644
index 000000000..f39f7f092
--- /dev/null
+++ b/packages/api/src/assistant-generation-access.ts
@@ -0,0 +1,34 @@
+import { createHmac, hkdfSync, timingSafeEqual } from "node:crypto";
+
+import { env } from "@homarr/common/env";
+
+const getGenerationTelemetrySecret = () =>
+ Buffer.from(
+ hkdfSync("sha256", Buffer.from(env.SECRET_ENCRYPTION_KEY, "hex"), "", "assistant-generation-telemetry", 32),
+ );
+
+const getGenerationTelemetryPayload = ({
+ userId,
+ threadId,
+ generationId,
+}: {
+ userId: string;
+ threadId: string;
+ generationId: string;
+}) => `${userId}\0${threadId}\0${generationId}`;
+
+export const createAssistantGenerationAccessToken = (input: {
+ userId: string;
+ threadId: string;
+ generationId: string;
+}) =>
+ createHmac("sha256", getGenerationTelemetrySecret()).update(getGenerationTelemetryPayload(input)).digest("base64url");
+
+export const verifyAssistantGenerationAccessToken = (
+ input: { userId: string; threadId: string; generationId: string },
+ accessToken: string,
+) => {
+ const expected = Buffer.from(createAssistantGenerationAccessToken(input));
+ const actual = Buffer.from(accessToken);
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
+};
diff --git a/packages/api/src/assistant-generation-telemetry.ts b/packages/api/src/assistant-generation-telemetry.ts
new file mode 100644
index 000000000..418fa6c55
--- /dev/null
+++ b/packages/api/src/assistant-generation-telemetry.ts
@@ -0,0 +1,167 @@
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+const generationTelemetryCache = new Map();
+const generationTelemetryCacheTtlMs = 5 * 60_000;
+const generationTelemetryCacheMaxEntries = 512;
+
+export type OpenRouterGenerationTelemetry = {
+ generationId?: string;
+ routedProvider?: string;
+ providerLatencyMs?: number;
+ generationTimeMs?: number;
+ moderationLatencyMs?: number;
+ providerOutputTokensPerSecond?: number;
+ inputTokens?: number;
+ outputTokens?: number;
+ normalizedInputTokens?: number;
+ normalizedOutputTokens?: number;
+ cachedInputTokens?: number;
+ reasoningTokens?: number;
+ cost?: number;
+ upstreamCost?: number;
+ cacheDiscount?: number;
+ finishReason?: string;
+ nativeFinishReason?: string;
+ serviceTier?: string;
+ dataRegion?: string;
+ fallbackCount?: number;
+ fallbackLatencyMs?: number;
+ isByok?: boolean;
+ streamed?: boolean;
+ cancelled?: boolean;
+};
+
+const asRecord = (value: unknown): Record | null =>
+ typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : null;
+
+const asFiniteNonNegativeNumber = (value: unknown) => {
+ const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
+ return Number.isFinite(number) && number >= 0 ? number : undefined;
+};
+
+const asString = (value: unknown) => (typeof value === "string" && value.length > 0 ? value : undefined);
+const asBoolean = (value: unknown) => (typeof value === "boolean" ? value : undefined);
+
+export const parseOpenRouterGenerationTelemetry = (value: unknown): OpenRouterGenerationTelemetry | null => {
+ const root = asRecord(value);
+ const data = asRecord(root?.data) ?? root;
+ if (!data) return null;
+
+ const normalizedInputTokens = asFiniteNonNegativeNumber(data.tokens_prompt);
+ const normalizedOutputTokens = asFiniteNonNegativeNumber(data.tokens_completion);
+ const inputTokens = asFiniteNonNegativeNumber(data.native_tokens_prompt) ?? normalizedInputTokens;
+ const outputTokens = asFiniteNonNegativeNumber(data.native_tokens_completion) ?? normalizedOutputTokens;
+ const generationTimeMs = asFiniteNonNegativeNumber(data.generation_time);
+ const providerResponses = Array.isArray(data.provider_responses) ? data.provider_responses : undefined;
+ const parsedProviderResponses = providerResponses?.map(asRecord).filter((response) => response !== null);
+ const providerResponseStatuses = parsedProviderResponses?.map((response) =>
+ asFiniteNonNegativeNumber(response.status),
+ );
+ const failedProviderResponses = parsedProviderResponses?.filter((response) => {
+ const status = asFiniteNonNegativeNumber(response.status);
+ return status !== undefined && (status < 200 || status >= 300);
+ });
+ const fallbackCount = providerResponses
+ ? providerResponseStatuses?.length === providerResponses.length &&
+ providerResponseStatuses.every((status) => status !== undefined)
+ ? (failedProviderResponses?.length ?? 0)
+ : Math.max(providerResponses.length - 1, 0)
+ : undefined;
+ const fallbackLatencies = failedProviderResponses?.map((response) => asFiniteNonNegativeNumber(response.latency));
+ const fallbackLatencyMs =
+ fallbackLatencies && fallbackLatencies.length > 0 && fallbackLatencies.every((latency) => latency !== undefined)
+ ? (fallbackLatencies as number[]).reduce((sum, latency) => sum + latency, 0)
+ : undefined;
+ const providerOutputTokensPerSecond =
+ outputTokens !== undefined && generationTimeMs !== undefined && generationTimeMs > 0
+ ? outputTokens / (generationTimeMs / 1000)
+ : undefined;
+ const generationId = asString(data.id);
+ const routedProvider = asString(data.provider_name);
+ const finishReason = asString(data.finish_reason);
+ const nativeFinishReason = asString(data.native_finish_reason);
+ const serviceTier = asString(data.service_tier);
+ const dataRegion = asString(data.data_region);
+
+ const telemetry: OpenRouterGenerationTelemetry = {
+ ...(generationId ? { generationId } : {}),
+ ...(routedProvider ? { routedProvider } : {}),
+ ...(asFiniteNonNegativeNumber(data.latency) !== undefined
+ ? { providerLatencyMs: asFiniteNonNegativeNumber(data.latency) }
+ : {}),
+ ...(generationTimeMs !== undefined ? { generationTimeMs } : {}),
+ ...(asFiniteNonNegativeNumber(data.moderation_latency) !== undefined
+ ? { moderationLatencyMs: asFiniteNonNegativeNumber(data.moderation_latency) }
+ : {}),
+ ...(providerOutputTokensPerSecond !== undefined ? { providerOutputTokensPerSecond } : {}),
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
+ ...(normalizedInputTokens !== undefined ? { normalizedInputTokens } : {}),
+ ...(normalizedOutputTokens !== undefined ? { normalizedOutputTokens } : {}),
+ ...(asFiniteNonNegativeNumber(data.native_tokens_cached) !== undefined
+ ? { cachedInputTokens: asFiniteNonNegativeNumber(data.native_tokens_cached) }
+ : {}),
+ ...(asFiniteNonNegativeNumber(data.native_tokens_reasoning) !== undefined
+ ? { reasoningTokens: asFiniteNonNegativeNumber(data.native_tokens_reasoning) }
+ : {}),
+ ...(asFiniteNonNegativeNumber(data.total_cost) !== undefined
+ ? { cost: asFiniteNonNegativeNumber(data.total_cost) }
+ : {}),
+ ...(asFiniteNonNegativeNumber(data.upstream_inference_cost) !== undefined
+ ? { upstreamCost: asFiniteNonNegativeNumber(data.upstream_inference_cost) }
+ : {}),
+ ...(asFiniteNonNegativeNumber(data.cache_discount) !== undefined
+ ? { cacheDiscount: asFiniteNonNegativeNumber(data.cache_discount) }
+ : {}),
+ ...(finishReason ? { finishReason } : {}),
+ ...(nativeFinishReason ? { nativeFinishReason } : {}),
+ ...(serviceTier ? { serviceTier } : {}),
+ ...(dataRegion ? { dataRegion } : {}),
+ ...(fallbackCount !== undefined ? { fallbackCount } : {}),
+ ...(fallbackLatencyMs !== undefined ? { fallbackLatencyMs } : {}),
+ ...(asBoolean(data.is_byok) !== undefined ? { isByok: asBoolean(data.is_byok) } : {}),
+ ...(asBoolean(data.streamed) !== undefined ? { streamed: asBoolean(data.streamed) } : {}),
+ ...(asBoolean(data.cancelled) !== undefined ? { cancelled: asBoolean(data.cancelled) } : {}),
+ };
+
+ return Object.keys(telemetry).length > 0 ? telemetry : null;
+};
+
+export const fetchOpenRouterGenerationTelemetryAsync = async ({
+ baseUrl,
+ generationId,
+ headers,
+}: {
+ baseUrl: string;
+ generationId: string;
+ headers: Record;
+}): Promise => {
+ const cacheKey = `${baseUrl}\0${generationId}`;
+ const cached = generationTelemetryCache.get(cacheKey);
+ if (cached && cached.expiresAt > Date.now()) {
+ generationTelemetryCache.delete(cacheKey);
+ generationTelemetryCache.set(cacheKey, cached);
+ return cached.value;
+ }
+ if (cached) generationTelemetryCache.delete(cacheKey);
+
+ try {
+ const endpoint = new URL(`${baseUrl.replace(/\/$/, "")}/generation`);
+ endpoint.searchParams.set("id", generationId);
+ const response = await fetchWithTrustedCertificatesAsync(endpoint, { headers, timeout: 3000 });
+ if (!response.ok) return null;
+ const telemetry = parseOpenRouterGenerationTelemetry(await response.json());
+ if (!telemetry) return null;
+ if (generationTelemetryCache.size >= generationTelemetryCacheMaxEntries) {
+ const oldestKey = generationTelemetryCache.keys().next().value;
+ if (oldestKey !== undefined) generationTelemetryCache.delete(oldestKey);
+ }
+ generationTelemetryCache.set(cacheKey, {
+ expiresAt: Date.now() + generationTelemetryCacheTtlMs,
+ value: telemetry,
+ });
+ return telemetry;
+ } catch {
+ return null;
+ }
+};
diff --git a/packages/api/src/assistant-message-order.ts b/packages/api/src/assistant-message-order.ts
new file mode 100644
index 000000000..6e9774e66
--- /dev/null
+++ b/packages/api/src/assistant-message-order.ts
@@ -0,0 +1,101 @@
+interface OrderableMessage {
+ id: string;
+ parentId: string | null;
+}
+
+/**
+ * Orders thread messages so that a parent always precedes its children.
+ *
+ * The database can only order by `created_at`, which is second-granular on MySQL and SQLite. Two
+ * messages written in the same second therefore tie, and the client rebuilds the conversation tree
+ * from `parent_id`, so a child arriving before its parent corrupts the thread. Sorting here keeps
+ * the relative order of siblings from the incoming (already deterministic) list.
+ */
+export const orderMessagesByParent = (messages: TMessage[]): TMessage[] => {
+ if (messages.length <= 1) return messages;
+
+ const messagesById = new Map();
+ const incomingIndexById = new Map();
+ messages.forEach((message, index) => {
+ messagesById.set(message.id, message);
+ incomingIndexById.set(message.id, index);
+ });
+
+ const childrenByParent = new Map();
+ const resolvedParentIds = new Map();
+
+ for (const message of messages) {
+ // Treat a dangling parent reference as a root so the message is never dropped.
+ const parentId = message.parentId !== null && messagesById.has(message.parentId) ? message.parentId : null;
+ resolvedParentIds.set(message.id, parentId);
+ const siblings = childrenByParent.get(parentId);
+ if (siblings) siblings.push(message);
+ else childrenByParent.set(parentId, [message]);
+ }
+
+ const ordered: TMessage[] = [];
+ const visited = new Set();
+
+ // A conversation is one long parent chain, so the traversal depth grows with the message count.
+ // An explicit stack keeps a long thread from exhausting the call stack.
+ const visitFrom = (start: TMessage) => {
+ const stack: TMessage[] = [start];
+ while (stack.length > 0) {
+ const message = stack.pop();
+ if (!message || visited.has(message.id)) continue;
+ visited.add(message.id);
+ ordered.push(message);
+ const children = childrenByParent.get(message.id);
+ if (!children) continue;
+ // Pushed in reverse so the stack pops siblings back in their incoming order.
+ for (let index = children.length - 1; index >= 0; index--) {
+ const child = children[index];
+ if (child) stack.push(child);
+ }
+ }
+ };
+
+ for (const root of childrenByParent.get(null) ?? []) {
+ visitFrom(root);
+ }
+
+ // Whatever is left belongs to a parent cycle, which by definition has no root to start from.
+ // Corrupted data should still come back complete and in a sensible order, so each cycle is
+ // broken at its earliest member in the incoming order and traversed from there. Only that one
+ // back-edge can violate parent-before-child; every other message in the component is fine.
+ const findCycleEntry = (start: TMessage) => {
+ const path: TMessage[] = [start];
+ const positionInPath = new Map([[start.id, 0]]);
+ let current = start;
+
+ for (;;) {
+ const parentId = resolvedParentIds.get(current.id) ?? null;
+ if (parentId === null || visited.has(parentId)) return current;
+
+ const parent = messagesById.get(parentId);
+ if (!parent) return current;
+
+ const loopStart = positionInPath.get(parent.id);
+ if (loopStart !== undefined) {
+ // Closed the loop: the cycle is the tail of the walked path. Start from whichever of its
+ // members came first in the incoming list so the choice is deterministic.
+ return path.slice(loopStart).reduce((earliest, member) => {
+ const memberIndex = incomingIndexById.get(member.id) ?? 0;
+ const earliestIndex = incomingIndexById.get(earliest.id) ?? 0;
+ return memberIndex < earliestIndex ? member : earliest;
+ });
+ }
+
+ positionInPath.set(parent.id, path.length);
+ path.push(parent);
+ current = parent;
+ }
+ };
+
+ for (const message of messages) {
+ if (visited.has(message.id)) continue;
+ visitFrom(findCycleEntry(message));
+ }
+
+ return ordered;
+};
diff --git a/packages/api/src/assistant.ts b/packages/api/src/assistant.ts
new file mode 100644
index 000000000..e6ac0cee0
--- /dev/null
+++ b/packages/api/src/assistant.ts
@@ -0,0 +1,8 @@
+export {
+ getAssistantContextEntitiesAsync,
+ getAssistantModelsAsync,
+ getSelectedModelDetailsAsync,
+ type AssistantContextEntity,
+} from "./router/assistant";
+export type { OpenRouterGenerationTelemetry } from "./assistant-generation-telemetry";
+export { createAssistantGenerationAccessToken } from "./assistant-generation-access";
diff --git a/packages/api/src/mcp-tools.ts b/packages/api/src/mcp-tools.ts
new file mode 100644
index 000000000..b3e2d058e
--- /dev/null
+++ b/packages/api/src/mcp-tools.ts
@@ -0,0 +1,83 @@
+import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
+import { z } from "zod/v4";
+
+export interface McpMeta {
+ mcp?: {
+ enabled: boolean;
+ name?: string;
+ description?: string;
+ };
+}
+
+export interface McpTool {
+ name: string;
+ description: string;
+ pathInRouter: string[];
+ inputSchema: z.core.JSONSchema.JSONSchema;
+ inputValidator: z.ZodObject;
+}
+
+const emptyInput = z.object({});
+
+const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
+
+const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
+ const properties = (schema.properties ?? {}) as Record>;
+ const required = ((schema.required as string[] | undefined) ?? []).filter(
+ (key) => !("default" in (properties[key] ?? {})),
+ );
+
+ return {
+ ...schema,
+ type: "object",
+ properties,
+ required,
+ };
+};
+
+export function extractMcpToolsFromProcedures(
+ router: Router,
+): McpTool[] {
+ const tools: McpTool[] = [];
+
+ for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
+ const definition = procedure["_def"] as unknown as {
+ inputs?: z.ZodType[];
+ meta?: McpMeta;
+ };
+ const metadata = definition.meta?.mcp;
+ if (!metadata?.enabled) continue;
+
+ const inputs = (definition.inputs ?? []).filter((input): input is z.ZodType => input !== undefined);
+ const procedureInput =
+ inputs.length === 0
+ ? emptyInput
+ : inputs.length === 1
+ ? (inputs[0] ?? emptyInput)
+ : inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)
+ ? mergeInputs(inputs)
+ : null;
+
+ if (
+ procedureInput === null ||
+ (!(procedureInput instanceof z.ZodObject) && !procedureInput.safeParse(undefined).success)
+ ) {
+ console.warn(`[MCP] Procedure ${path} does not use an object input schema; using an empty schema`);
+ }
+ const inputValidator = procedureInput instanceof z.ZodObject ? procedureInput : emptyInput;
+
+ tools.push({
+ name: metadata.name ?? path.replaceAll(".", "_"),
+ description: metadata.description ?? "",
+ pathInRouter: path.split("."),
+ inputValidator,
+ inputSchema: normalizeJsonSchema(
+ z.toJSONSchema(inputValidator, {
+ unrepresentable: "any",
+ }),
+ ),
+ });
+ }
+
+ return tools.toSorted((left, right) => left.name.localeCompare(right.name));
+}
diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts
index c3939c983..06d023863 100644
--- a/packages/api/src/mcp.ts
+++ b/packages/api/src/mcp.ts
@@ -1,9 +1,12 @@
export { createTRPCContext } from "./trpc";
+export { extractMcpToolsFromProcedures } from "./mcp-tools";
+export type { McpTool } from "./mcp-tools";
import { createTRPCRouter } from "./trpc";
import { appRouter as appRouterForApps } from "./router/app";
import { apiKeysRouter } from "./router/apiKeys";
import { boardRouter } from "./router/board";
+import { customWidgetRouter } from "./router/custom-widget/custom-widget-router";
import { dockerRouter } from "./router/docker/docker-router";
import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
@@ -13,7 +16,6 @@ import { bazarrRouter } from "./router/widgets/bazarr";
import { serverSettingsRouter } from "./router/serverSettings";
import { beszelRouter } from "./router/widgets/beszel";
import { calendarRouter } from "./router/widgets/calendar";
-import { customWidgetRouter } from "./router/custom-widget/custom-widget-router";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
@@ -27,6 +29,7 @@ export const mcpRouter = createTRPCRouter({
app: appRouterForApps,
apiKeys: apiKeysRouter,
board: boardRouter,
+ customWidget: customWidgetRouter,
docker: dockerRouter,
icon: iconsRouter,
info: infoRouter,
@@ -36,7 +39,6 @@ export const mcpRouter = createTRPCRouter({
serverSettings: serverSettingsRouter,
beszel: beszelRouter,
calendar: calendarRouter,
- customWidget: customWidgetRouter,
dnsHole: dnsHoleRouter,
downloads: downloadsRouter,
healthMonitoring: healthMonitoringRouter,
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index 691e868b3..5115e2b8e 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
+ assistant: lazy(() => import("./router/assistant").then((mod) => mod.assistantRouter)),
user: lazy(() => import("./router/user").then((mod) => mod.userRouter)),
group: lazy(() => import("./router/group").then((mod) => mod.groupRouter)),
invite: lazy(() => import("./router/invite").then((mod) => mod.inviteRouter)),
diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts
new file mode 100644
index 000000000..3544038a1
--- /dev/null
+++ b/packages/api/src/router/assistant.ts
@@ -0,0 +1,911 @@
+import { TRPCError } from "@trpc/server";
+import { parse, stringify } from "superjson";
+import { z } from "zod/v4";
+
+import { constructIntegrationPermissions } from "@homarr/auth/shared";
+import { createId } from "@homarr/common";
+import { decryptSecret, encryptSecret } from "@homarr/common/server";
+import { and, asc, desc, eq, handleTransactionsAsync, inArray } from "@homarr/db";
+import type { Database } from "@homarr/db";
+import {
+ apps,
+ assistantConfigurations,
+ assistantMessages,
+ assistantThreads,
+ groupMembers,
+ integrationGroupPermissions,
+ integrations,
+ integrationUserPermissions,
+ items,
+} from "@homarr/db/schema";
+import {
+ assistantProviderCanUseOpenRouterServerTools,
+ assistantProviderIds,
+ assistantProviderPresets,
+ assistantProviderRequiresApiKey,
+ getIconUrl,
+ resolveAssistantModelId,
+} from "@homarr/definitions";
+
+import type { createTRPCContext } from "../trpc";
+import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
+import { orderMessagesByParent } from "../assistant-message-order";
+import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
+import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
+import { boardRouter } from "./board";
+
+const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
+const configurationId = "default";
+const providerSchema = z.enum(assistantProviderIds);
+
+const modelSchema = z.object({
+ id: z.string().min(1).max(256),
+ name: z.string().min(1).max(256),
+ description: z.string().max(2048).nullable(),
+ contextLength: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).nullable(),
+ promptPrice: z.string().max(64).nullable(),
+ completionPrice: z.string().max(64).nullable(),
+ inputModalities: z.array(z.string().max(32)).max(16),
+ toolSupport: z.enum(["confirmed", "unknown"]),
+});
+
+type CompatibleModel = {
+ id?: unknown;
+ name?: unknown;
+ display_name?: unknown;
+ model?: unknown;
+ description?: unknown;
+ context_length?: unknown;
+ max_context_length?: unknown;
+ max_input_tokens?: unknown;
+ supported_parameters?: unknown;
+ input_modalities?: unknown;
+ architecture?: { input_modalities?: unknown; output_modalities?: unknown };
+ pricing?: { prompt?: unknown; completion?: unknown };
+ capabilities?: { function_calling?: unknown };
+};
+
+type AssistantConfiguration = typeof assistantConfigurations.$inferSelect;
+type AssistantContext = Awaited>;
+
+export type AssistantContextEntity = {
+ id: string;
+ type: "app" | "integration" | "board" | "widget";
+ label: string;
+ description: string;
+ iconUrl?: string;
+ boardId?: string;
+};
+
+const modelListCache = new Map[] }>();
+
+const customHeadersSchema = z
+ .record(
+ z
+ .string()
+ .trim()
+ .min(1)
+ .max(128)
+ .regex(/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/),
+ z
+ .string()
+ .max(2048)
+ .refine((value) => !value.includes("\r") && !value.includes("\n")),
+ )
+ .refine((headers) => Object.keys(headers).length <= 20)
+ .refine(
+ (headers) =>
+ !Object.keys(headers).some((name) =>
+ ["connection", "content-length", "host", "transfer-encoding"].includes(name.toLowerCase()),
+ ),
+ "Hop-by-hop and transport headers cannot be configured.",
+ );
+
+const normalizeBaseUrl = (value: string) => {
+ let url: URL;
+ try {
+ url = new URL(value);
+ } catch {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Enter a valid provider URL." });
+ }
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "The provider URL must use HTTP or HTTPS without credentials, query parameters, or fragments.",
+ });
+ }
+ return url.toString().replace(/\/$/, "");
+};
+
+const normalizeDiscoveryPath = (value: string | null) => {
+ if (value === null || value.trim() === "") return null;
+ const path = value.trim();
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("?") || path.includes("#")) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "The model discovery path must be an absolute path such as /models.",
+ });
+ }
+ return path;
+};
+
+const getConfigurationAsync = async (db: Database) => {
+ return await db.query.assistantConfigurations.findFirst({
+ where: eq(assistantConfigurations.id, configurationId),
+ });
+};
+
+const decryptCustomHeaders = (encryptedHeaders: `${string}.${string}` | null) => {
+ if (!encryptedHeaders) return {};
+ try {
+ return customHeadersSchema.parse(JSON.parse(decryptSecret(encryptedHeaders)));
+ } catch {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "The stored provider headers could not be decrypted.",
+ });
+ }
+};
+
+const getProviderHeaders = (configuration: AssistantConfiguration) => {
+ const headers: Record = {};
+ if (configuration.encryptedApiKey) {
+ const apiKey = decryptSecret(configuration.encryptedApiKey);
+ if (assistantProviderPresets[configuration.provider].discoveryAuthentication === "anthropic") {
+ headers["X-Api-Key"] = apiKey;
+ headers["anthropic-version"] = "2023-06-01";
+ } else {
+ headers.Authorization = `Bearer ${apiKey}`;
+ }
+ }
+ if (configuration.provider === "openrouter") {
+ headers["HTTP-Referer"] ??= "https://homarr.dev";
+ headers["X-Title"] ??= "Homarr Assistant";
+ }
+ return { ...headers, ...decryptCustomHeaders(configuration.encryptedHeaders) };
+};
+
+const getGenerationIdsFromMessageContent = (content: unknown) => {
+ if (!content || typeof content !== "object" || Array.isArray(content)) return [];
+ const metadata = (content as { metadata?: unknown }).metadata;
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return [];
+ const custom = (metadata as { custom?: unknown }).custom;
+ if (!custom || typeof custom !== "object" || Array.isArray(custom)) return [];
+ const telemetry = (custom as { telemetry?: unknown }).telemetry;
+ if (!telemetry || typeof telemetry !== "object" || Array.isArray(telemetry)) return [];
+ const steps = (telemetry as { steps?: unknown }).steps;
+ if (!Array.isArray(steps)) return [];
+ return steps
+ .flatMap((step) => {
+ if (!step || typeof step !== "object" || Array.isArray(step)) return [];
+ const generationId = (step as { generationId?: unknown }).generationId;
+ const accessToken = (step as { generationAccessToken?: unknown }).generationAccessToken;
+ return typeof generationId === "string" &&
+ /^gen-[A-Za-z0-9_-]{1,128}$/.test(generationId) &&
+ typeof accessToken === "string" &&
+ /^[A-Za-z0-9_-]{43}$/.test(accessToken)
+ ? [{ generationId, accessToken }]
+ : [];
+ })
+ .slice(0, 8);
+};
+
+const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
+ if (!configuration.modelDiscoveryPath) {
+ throw new TRPCError({
+ code: "PRECONDITION_FAILED",
+ message: "Model discovery is disabled for this provider. Enter a model ID manually.",
+ });
+ }
+
+ const endpoint = `${configuration.baseUrl}${configuration.modelDiscoveryPath}`;
+ let response: Response;
+ try {
+ response = await fetch(endpoint, {
+ headers: getProviderHeaders(configuration),
+ signal: AbortSignal.timeout(15_000),
+ });
+ } catch {
+ throw new TRPCError({
+ code: "BAD_GATEWAY",
+ message: "The model endpoint could not be reached from the Homarr server.",
+ });
+ }
+
+ if (!response.ok) {
+ if (response.status === 401 || response.status === 403) {
+ throw new TRPCError({ code: "UNAUTHORIZED", message: "The provider rejected the configured credentials." });
+ }
+ throw new TRPCError({
+ code: "BAD_GATEWAY",
+ message: `Model discovery failed with status ${response.status}.`,
+ });
+ }
+
+ let body: { data?: CompatibleModel[]; models?: CompatibleModel[] } | CompatibleModel[];
+ try {
+ body = (await response.json()) as typeof body;
+ } catch {
+ throw new TRPCError({ code: "BAD_GATEWAY", message: "The model endpoint returned invalid JSON." });
+ }
+ const models = (Array.isArray(body) ? body : (body.data ?? body.models ?? [])).slice(0, 1_000);
+ return models
+ .filter((model) => {
+ if (configuration.provider !== "openrouter") return true;
+ const parameters = Array.isArray(model.supported_parameters) ? model.supported_parameters : [];
+ const outputModalities = Array.isArray(model.architecture?.output_modalities)
+ ? model.architecture.output_modalities
+ : [];
+ return parameters.includes("tools") && (outputModalities.length === 0 || outputModalities.includes("text"));
+ })
+ .flatMap((model) => {
+ const id = typeof model.id === "string" ? model.id : typeof model.model === "string" ? model.model : null;
+ if (!id || id.length > 256) return [];
+ const rawName =
+ typeof model.name === "string" ? model.name : typeof model.display_name === "string" ? model.display_name : id;
+ const name = rawName.trim() || id;
+ const contextLength =
+ typeof model.context_length === "number" &&
+ Number.isSafeInteger(model.context_length) &&
+ model.context_length > 0
+ ? model.context_length
+ : typeof model.max_context_length === "number" &&
+ Number.isSafeInteger(model.max_context_length) &&
+ model.max_context_length > 0
+ ? model.max_context_length
+ : typeof model.max_input_tokens === "number" &&
+ Number.isSafeInteger(model.max_input_tokens) &&
+ model.max_input_tokens > 0
+ ? model.max_input_tokens
+ : null;
+ const toolSupport =
+ configuration.provider === "openrouter" ||
+ model.capabilities?.function_calling === true ||
+ configuration.provider === "anthropic"
+ ? ("confirmed" as const)
+ : ("unknown" as const);
+ const rawInputModalities = Array.isArray(model.architecture?.input_modalities)
+ ? model.architecture.input_modalities
+ : Array.isArray(model.input_modalities)
+ ? model.input_modalities
+ : [];
+ const inputModalities = rawInputModalities
+ .filter((modality): modality is string => typeof modality === "string" && modality.length <= 32)
+ .slice(0, 16);
+ return [
+ {
+ id,
+ name: name.slice(0, 256),
+ description: typeof model.description === "string" ? model.description.slice(0, 2048) : null,
+ contextLength,
+ promptPrice: typeof model.pricing?.prompt === "string" ? model.pricing.prompt.slice(0, 64) : null,
+ completionPrice: typeof model.pricing?.completion === "string" ? model.pricing.completion.slice(0, 64) : null,
+ inputModalities,
+ toolSupport,
+ },
+ ];
+ })
+ .toSorted((left, right) => left.name.localeCompare(right.name));
+};
+
+const getModelListCacheKey = (configuration: AssistantConfiguration) =>
+ [
+ configuration.provider,
+ configuration.baseUrl,
+ configuration.modelDiscoveryPath,
+ configuration.updatedAt.getTime(),
+ ].join(":");
+
+export const getAssistantModelsAsync = async (configuration: AssistantConfiguration, refresh = false) => {
+ if (!configuration.modelDiscoveryPath) return [];
+ const cacheKey = getModelListCacheKey(configuration);
+ const cached = modelListCache.get(cacheKey);
+ if (!refresh && cached && cached.expiresAt > Date.now()) return cached.value;
+
+ const value = await fetchModelsAsync(configuration);
+ modelListCache.clear();
+ modelListCache.set(cacheKey, { expiresAt: Date.now() + 10 * 60_000, value });
+ return value;
+};
+
+export const getSelectedModelDetailsAsync = async (
+ configuration: AssistantConfiguration,
+ requestedModelId = configuration.modelId,
+) => {
+ if (!requestedModelId || !configuration.modelDiscoveryPath) return null;
+ const models = await getAssistantModelsAsync(configuration);
+ const resolvedModelId = resolveAssistantModelId(models, requestedModelId);
+ return models.find((model) => model.id === resolvedModelId) ?? null;
+};
+
+export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): Promise => {
+ if (!ctx.session) return [];
+
+ const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, ctx.session.user.id),
+ });
+ const [availableBoards, availableIntegrations, availableApps] = await Promise.all([
+ boardRouter.createCaller(ctx).getAllBoards(),
+ ctx.db.query.integrations.findMany({
+ columns: { id: true, name: true, kind: true },
+ with: {
+ userPermissions: {
+ where: eq(integrationUserPermissions.userId, ctx.session.user.id),
+ },
+ groupPermissions: {
+ where: inArray(
+ integrationGroupPermissions.groupId,
+ groupsOfCurrentUser.map((group) => group.groupId).concat(""),
+ ),
+ },
+ },
+ orderBy: asc(integrations.name),
+ limit: 250,
+ }),
+ ctx.db.query.apps.findMany({
+ columns: { id: true, name: true, description: true, iconUrl: true },
+ orderBy: asc(apps.name),
+ limit: 250,
+ }),
+ ]);
+ const boardsById = new Map(availableBoards.map((board) => [board.id, board]));
+ const availableItems =
+ availableBoards.length === 0
+ ? []
+ : await ctx.db.query.items.findMany({
+ columns: { id: true, boardId: true, kind: true },
+ where: inArray(
+ items.boardId,
+ availableBoards.map((board) => board.id),
+ ),
+ orderBy: asc(items.kind),
+ limit: 500,
+ });
+
+ return [
+ ...availableApps.map(
+ (app): AssistantContextEntity => ({
+ id: app.id,
+ type: "app",
+ label: app.name,
+ description: app.description ?? "Homarr app",
+ iconUrl: app.iconUrl,
+ }),
+ ),
+ ...availableIntegrations
+ .filter((integration) => constructIntegrationPermissions(integration, ctx.session).hasUseAccess)
+ .map(
+ (integration): AssistantContextEntity => ({
+ id: integration.id,
+ type: "integration",
+ label: integration.name,
+ description: `${integration.kind} integration`,
+ iconUrl: getIconUrl(integration.kind),
+ }),
+ ),
+ ...availableBoards.map(
+ (board): AssistantContextEntity => ({
+ id: board.id,
+ type: "board",
+ label: board.name,
+ description: board.isHome ? "Home board" : board.isMobileHome ? "Mobile home board" : "Homarr board",
+ }),
+ ),
+ ...availableItems.flatMap((item): AssistantContextEntity[] => {
+ const board = boardsById.get(item.boardId);
+ if (!board) return [];
+ return [
+ {
+ id: item.id,
+ type: "widget",
+ label: `${item.kind} · ${board.name}`,
+ description: `${item.kind} widget on ${board.name}`,
+ boardId: board.id,
+ },
+ ];
+ }),
+ ];
+};
+
+const ownedThreadAsync = async (db: Database, threadId: string, userId: string) => {
+ const thread = await db.query.assistantThreads.findFirst({
+ where: and(eq(assistantThreads.id, threadId), eq(assistantThreads.userId, userId)),
+ });
+ if (!thread) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Conversation not found." });
+ }
+ return thread;
+};
+
+const addFeedbackToMessageContent = (serializedContent: string, type: "positive" | "negative") => {
+ let content: unknown;
+ try {
+ content = parse(serializedContent);
+ } catch {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "The stored message is invalid." });
+ }
+ if (!content || typeof content !== "object" || Array.isArray(content)) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "The stored message is invalid." });
+ }
+ const metadata =
+ "metadata" in content &&
+ content.metadata &&
+ typeof content.metadata === "object" &&
+ !Array.isArray(content.metadata)
+ ? content.metadata
+ : {};
+ return stringify({
+ ...content,
+ metadata: { ...metadata, submittedFeedback: { type } },
+ });
+};
+
+export const assistantRouter = createTRPCRouter({
+ getAvailability: protectedProcedure.query(async ({ ctx }) => {
+ if (isDemoMode) {
+ return { enabled: true };
+ }
+ const configuration = await getConfigurationAsync(ctx.db);
+ const requiresApiKey = configuration ? assistantProviderRequiresApiKey(configuration.provider) : false;
+ return {
+ enabled: Boolean(
+ configuration?.enabled && configuration.modelId && (!requiresApiKey || configuration.encryptedApiKey),
+ ),
+ };
+ }),
+
+ getContextEntities: protectedProcedure.query(async ({ ctx }) => {
+ return await getAssistantContextEntitiesAsync(ctx);
+ }),
+
+ getModelCapabilities: protectedProcedure
+ .input(z.object({ modelId: z.string().trim().min(1).max(256).optional() }).optional())
+ .query(async ({ ctx, input }) => {
+ const configuration = await getConfigurationAsync(ctx.db);
+ if (!configuration?.enabled || !configuration.modelId) {
+ return { imageInput: "unsupported" as const, inputModalities: [] as string[] };
+ }
+ const model = await getSelectedModelDetailsAsync(configuration, input?.modelId).catch(() => null);
+ if (!model || model.inputModalities.length === 0) {
+ return { imageInput: "unknown" as const, inputModalities: [] as string[] };
+ }
+ return {
+ imageInput: model.inputModalities.includes("image") ? ("supported" as const) : ("unsupported" as const),
+ inputModalities: model.inputModalities,
+ };
+ }),
+
+ getRuntimeOptions: protectedProcedure.query(async ({ ctx }) => {
+ const configuration = await getConfigurationAsync(ctx.db);
+ if (isDemoMode) {
+ return {
+ provider: "homarr",
+ defaultModelId: "homarr-assistant",
+ models: [
+ {
+ id: "homarr-assistant",
+ name: "Homarr Assistant (Demo)",
+ description: null,
+ contextLength: null,
+ promptPrice: null,
+ completionPrice: null,
+ inputModalities: [],
+ toolSupport: "unknown" as const,
+ },
+ ],
+ };
+ }
+ if (!configuration?.enabled || !configuration.modelId) {
+ throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
+ }
+ const discoveredModels = await getAssistantModelsAsync(configuration).catch(() => []);
+ const resolvedDefaultId = resolveAssistantModelId(discoveredModels, configuration.modelId) ?? configuration.modelId;
+ const defaultModel = discoveredModels.find((model) => model.id === resolvedDefaultId) ?? {
+ id: resolvedDefaultId,
+ name: resolvedDefaultId,
+ description: null,
+ contextLength: null,
+ promptPrice: null,
+ completionPrice: null,
+ inputModalities: [],
+ toolSupport: "unknown" as const,
+ };
+ return {
+ provider: configuration.provider,
+ defaultModelId: defaultModel.id,
+ models: [defaultModel, ...discoveredModels.filter((model) => model.id !== defaultModel.id)],
+ };
+ }),
+
+ getAdminConfiguration: adminProcedure.query(async ({ ctx }) => {
+ const configuration = await getConfigurationAsync(ctx.db);
+ return {
+ connectionConfigured: Boolean(configuration),
+ enabled: configuration?.enabled ?? false,
+ webSearchEnabled: configuration?.webSearchEnabled ?? false,
+ provider: configuration?.provider ?? "openrouter",
+ baseUrl: configuration?.baseUrl ?? "https://openrouter.ai/api/v1",
+ modelDiscoveryPath: configuration?.modelDiscoveryPath ?? "/models",
+ apiKeyConfigured: Boolean(configuration?.encryptedApiKey),
+ customHeadersConfigured: Boolean(configuration?.encryptedHeaders),
+ modelId: configuration?.modelId ?? null,
+ updatedAt: configuration?.updatedAt ?? null,
+ };
+ }),
+
+ updateConnection: adminProcedure
+ .input(
+ z.object({
+ provider: providerSchema,
+ baseUrl: z.string().trim().min(1).max(2048),
+ modelDiscoveryPath: z.string().trim().max(512).nullable(),
+ apiKey: z.string().trim().min(1).max(2048).optional(),
+ clearApiKey: z.boolean().default(false),
+ customHeaders: customHeadersSchema.optional(),
+ clearCustomHeaders: z.boolean().default(false),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const baseUrl = normalizeBaseUrl(input.baseUrl);
+ const modelDiscoveryPath = normalizeDiscoveryPath(input.modelDiscoveryPath);
+ const existing = await getConfigurationAsync(ctx.db);
+ const destinationChanged =
+ existing !== undefined && (existing.provider !== input.provider || existing.baseUrl !== baseUrl);
+ const connectionChanged =
+ existing !== undefined && (destinationChanged || existing.modelDiscoveryPath !== modelDiscoveryPath);
+ const encryptedApiKey = input.apiKey
+ ? encryptSecret(input.apiKey)
+ : input.clearApiKey || destinationChanged
+ ? null
+ : (existing?.encryptedApiKey ?? null);
+ const encryptedHeaders = input.customHeaders
+ ? encryptSecret(JSON.stringify(input.customHeaders))
+ : input.clearCustomHeaders || destinationChanged
+ ? null
+ : (existing?.encryptedHeaders ?? null);
+
+ if (existing) {
+ await ctx.db
+ .update(assistantConfigurations)
+ .set({
+ provider: input.provider,
+ baseUrl,
+ modelDiscoveryPath,
+ encryptedApiKey,
+ encryptedHeaders,
+ modelId: connectionChanged ? null : existing.modelId,
+ enabled: connectionChanged ? false : existing.enabled,
+ webSearchEnabled:
+ destinationChanged || !assistantProviderCanUseOpenRouterServerTools(input.provider)
+ ? false
+ : existing.webSearchEnabled,
+ updatedAt: new Date(),
+ })
+ .where(eq(assistantConfigurations.id, configurationId));
+ } else {
+ await ctx.db.insert(assistantConfigurations).values({
+ id: configurationId,
+ provider: input.provider,
+ baseUrl,
+ modelDiscoveryPath,
+ encryptedApiKey,
+ encryptedHeaders,
+ });
+ }
+
+ return {
+ apiKeyConfigured: Boolean(encryptedApiKey),
+ customHeadersConfigured: Boolean(encryptedHeaders),
+ credentialsClearedForDestinationChange: destinationChanged && !input.apiKey && !input.customHeaders,
+ };
+ }),
+
+ clearCredentials: adminProcedure.mutation(async ({ ctx }) => {
+ const existing = await getConfigurationAsync(ctx.db);
+ if (!existing) return;
+ await ctx.db
+ .update(assistantConfigurations)
+ .set({
+ encryptedApiKey: null,
+ encryptedHeaders: null,
+ modelId: null,
+ enabled: false,
+ updatedAt: new Date(),
+ })
+ .where(eq(assistantConfigurations.id, configurationId));
+ }),
+
+ discoverModels: adminProcedure.output(z.array(modelSchema)).query(async ({ ctx }) => {
+ const configuration = await getConfigurationAsync(ctx.db);
+ if (!configuration) {
+ throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Save a provider connection first." });
+ }
+ return await getAssistantModelsAsync(configuration, true);
+ }),
+
+ updateConfiguration: adminProcedure
+ .input(
+ z.object({
+ enabled: z.boolean(),
+ modelId: z.string().trim().min(1).max(256),
+ webSearchEnabled: z.boolean().default(false),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const configuration = await getConfigurationAsync(ctx.db);
+ if (!configuration) {
+ throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Save a provider connection first." });
+ }
+ const requiresApiKey = assistantProviderRequiresApiKey(configuration.provider);
+ if (input.enabled && requiresApiKey && !configuration.encryptedApiKey) {
+ throw new TRPCError({ code: "PRECONDITION_FAILED", message: "This provider requires an API key." });
+ }
+ if (input.webSearchEnabled && !assistantProviderCanUseOpenRouterServerTools(configuration.provider)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Web search requires OpenRouter or an OpenRouter-compatible proxy endpoint.",
+ });
+ }
+ const discoveredModels = configuration.modelDiscoveryPath
+ ? await fetchModelsAsync(configuration).catch(() => null)
+ : null;
+ const modelId = discoveredModels
+ ? (resolveAssistantModelId(discoveredModels, input.modelId) ?? input.modelId)
+ : input.modelId;
+ await ctx.db
+ .update(assistantConfigurations)
+ .set({ enabled: input.enabled, modelId, webSearchEnabled: input.webSearchEnabled, updatedAt: new Date() })
+ .where(eq(assistantConfigurations.id, configurationId));
+ }),
+
+ listThreads: protectedProcedure.query(async ({ ctx }) => {
+ return await ctx.db.query.assistantThreads.findMany({
+ where: eq(assistantThreads.userId, ctx.session.user.id),
+ orderBy: desc(assistantThreads.updatedAt),
+ limit: 100,
+ });
+ }),
+
+ createThread: protectedProcedure
+ .input(z.object({ localId: z.string().max(128).optional() }).optional())
+ .mutation(async ({ ctx }) => {
+ const configuration = await getConfigurationAsync(ctx.db);
+ const id = createId();
+ const now = new Date();
+ await ctx.db.insert(assistantThreads).values({
+ id,
+ userId: ctx.session.user.id,
+ modelId: configuration?.modelId ?? null,
+ createdAt: now,
+ updatedAt: now,
+ });
+ return { id };
+ }),
+
+ updateThreadModel: protectedProcedure
+ .input(z.object({ threadId: z.string().max(64), modelId: z.string().trim().min(1).max(256) }))
+ .mutation(async ({ ctx, input }) => {
+ const [thread, configuration] = await Promise.all([
+ ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id),
+ getConfigurationAsync(ctx.db),
+ ]);
+ if (!configuration?.enabled || !configuration.modelId) {
+ throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Homarr Assistant is not configured." });
+ }
+ const selectedModel = await getSelectedModelDetailsAsync(configuration, input.modelId).catch(() => null);
+ if (input.modelId !== configuration.modelId && !selectedModel) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "The selected model is not available from the configured provider.",
+ });
+ }
+ const modelId = selectedModel?.id ?? configuration.modelId;
+ await ctx.db
+ .update(assistantThreads)
+ .set({ modelId, updatedAt: new Date() })
+ .where(eq(assistantThreads.id, thread.id));
+ return { modelId };
+ }),
+
+ getThread: protectedProcedure.input(z.object({ threadId: z.string().max(64) })).query(async ({ ctx, input }) => {
+ const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
+ const messages = await ctx.db.query.assistantMessages.findMany({
+ where: eq(assistantMessages.threadId, thread.id),
+ // `created_at` is only second-granular on MySQL and SQLite, so sibling messages written in the
+ // same second tie. The id tiebreaker makes the database order at least deterministic.
+ orderBy: [asc(assistantMessages.createdAt), asc(assistantMessages.id)],
+ });
+ return {
+ thread,
+ messages: orderMessagesByParent(messages).map((message) => ({
+ id: message.id,
+ parentId: message.parentId,
+ format: message.format,
+ content: parse(message.content),
+ })),
+ };
+ }),
+
+ getGenerationTelemetry: protectedProcedure
+ .input(
+ z.object({
+ threadId: z.string().max(64),
+ messageId: z.string().min(1).max(128),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
+ const [message, configuration] = await Promise.all([
+ ctx.db.query.assistantMessages.findFirst({
+ where: and(eq(assistantMessages.id, input.messageId), eq(assistantMessages.threadId, thread.id)),
+ }),
+ getConfigurationAsync(ctx.db),
+ ]);
+ if (!message) return { complete: true, generations: [] };
+ if (configuration?.provider !== "openrouter" || !configuration.encryptedApiKey) {
+ return { complete: true, generations: [] };
+ }
+
+ const generationReferences = getGenerationIdsFromMessageContent(parse(message.content)).filter((reference) =>
+ verifyAssistantGenerationAccessToken(
+ {
+ userId: ctx.session.user.id,
+ threadId: thread.id,
+ generationId: reference.generationId,
+ },
+ reference.accessToken,
+ ),
+ );
+ if (generationReferences.length === 0) return { complete: true, generations: [] };
+ const generations = await Promise.all(
+ generationReferences.map(({ generationId }) =>
+ fetchOpenRouterGenerationTelemetryAsync({
+ baseUrl: configuration.baseUrl,
+ generationId,
+ headers: getProviderHeaders(configuration),
+ }),
+ ),
+ );
+ return {
+ complete: generations.every((generation) => generation !== null),
+ generations: generations.filter((generation) => generation !== null),
+ };
+ }),
+
+ renameThread: protectedProcedure
+ .input(z.object({ threadId: z.string().max(64), title: z.string().trim().min(1).max(120) }))
+ .mutation(async ({ ctx, input }) => {
+ await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
+ await ctx.db
+ .update(assistantThreads)
+ .set({ title: input.title, updatedAt: new Date() })
+ .where(eq(assistantThreads.id, input.threadId));
+ }),
+
+ deleteThread: protectedProcedure
+ .input(z.object({ threadId: z.string().max(64) }))
+ .mutation(async ({ ctx, input }) => {
+ await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
+ await ctx.db.delete(assistantThreads).where(eq(assistantThreads.id, input.threadId));
+ }),
+
+ appendMessage: protectedProcedure
+ .input(
+ z.object({
+ threadId: z.string().max(64),
+ id: z.string().min(1).max(128),
+ parentId: z.string().max(128).nullable(),
+ format: z.literal("ai-sdk/v6"),
+ content: z.unknown(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
+ const existing = await ctx.db.query.assistantMessages.findFirst({
+ where: and(eq(assistantMessages.id, input.id), eq(assistantMessages.threadId, thread.id)),
+ });
+ if (existing) {
+ await ctx.db
+ .update(assistantMessages)
+ .set({ parentId: input.parentId, format: input.format, content: stringify(input.content) })
+ .where(eq(assistantMessages.id, input.id));
+ } else {
+ await ctx.db.insert(assistantMessages).values({
+ id: input.id,
+ threadId: thread.id,
+ parentId: input.parentId,
+ format: input.format,
+ content: stringify(input.content),
+ });
+ }
+
+ const title =
+ thread.title ??
+ (typeof input.content === "object" &&
+ input.content !== null &&
+ "role" in input.content &&
+ input.content.role === "user" &&
+ "parts" in input.content &&
+ Array.isArray(input.content.parts)
+ ? input.content.parts
+ .filter(
+ (part): part is { type: "text"; text: string } =>
+ typeof part === "object" &&
+ part !== null &&
+ "type" in part &&
+ part.type === "text" &&
+ "text" in part &&
+ typeof part.text === "string",
+ )
+ .map((part) => part.text)
+ .join(" ")
+ .trim()
+ .slice(0, 80) || null
+ : null);
+
+ await ctx.db
+ .update(assistantThreads)
+ .set({ title, updatedAt: new Date() })
+ .where(eq(assistantThreads.id, thread.id));
+ }),
+
+ submitFeedback: protectedProcedure
+ .input(
+ z.object({
+ threadId: z.string().max(64),
+ messageId: z.string().min(1).max(128),
+ type: z.enum(["positive", "negative"]),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const thread = await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
+ await handleTransactionsAsync(ctx.db, {
+ handleAsync: async (db, schema) => {
+ await db.transaction(async (transaction) => {
+ const table = schema.assistantMessages;
+ const [message] = await transaction
+ .select({ content: table.content })
+ .from(table)
+ .where(and(eq(table.id, input.messageId), eq(table.threadId, thread.id)))
+ .for("update");
+ if (!message) throw new TRPCError({ code: "NOT_FOUND", message: "Message not found." });
+ await transaction
+ .update(table)
+ .set({ content: addFeedbackToMessageContent(message.content, input.type) })
+ .where(and(eq(table.id, input.messageId), eq(table.threadId, thread.id)));
+ });
+ },
+ handleSync: (db) => {
+ db.transaction((transaction) => {
+ const message = transaction
+ .select({ content: assistantMessages.content })
+ .from(assistantMessages)
+ .where(and(eq(assistantMessages.id, input.messageId), eq(assistantMessages.threadId, thread.id)))
+ .get();
+ if (!message) throw new TRPCError({ code: "NOT_FOUND", message: "Message not found." });
+ transaction
+ .update(assistantMessages)
+ .set({ content: addFeedbackToMessageContent(message.content, input.type) })
+ .where(and(eq(assistantMessages.id, input.messageId), eq(assistantMessages.threadId, thread.id)))
+ .run();
+ });
+ },
+ });
+ }),
+
+ deleteMessages: protectedProcedure
+ .input(z.object({ threadId: z.string().max(64), ids: z.array(z.string().max(128)).max(100) }))
+ .mutation(async ({ ctx, input }) => {
+ await ownedThreadAsync(ctx.db, input.threadId, ctx.session.user.id);
+ if (input.ids.length === 0) return;
+ await ctx.db
+ .delete(assistantMessages)
+ .where(and(eq(assistantMessages.threadId, input.threadId), inArray(assistantMessages.id, input.ids)));
+ await ctx.db
+ .update(assistantThreads)
+ .set({ updatedAt: new Date() })
+ .where(eq(assistantThreads.id, input.threadId));
+ }),
+});
+
+export { fetchModelsAsync, getConfigurationAsync, ownedThreadAsync };
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index 3053da760..8b5ec550f 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -2,13 +2,15 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod/v4";
-import { createLogger } from "@homarr/core/infrastructure/logs";
import { constructBoardPermissions } from "@homarr/auth/shared";
-import { createId } from "@homarr/common";
+import { createId, generateResponsiveGridFor } from "@homarr/common";
+import type { GridAlgorithmItem } from "@homarr/common";
import type { DeviceType } from "@homarr/common/server";
+import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
+import { seedProtectedBoardLayoutsAsync } from "@homarr/db/migrations/seed";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
boardGroupPermissions,
@@ -38,8 +40,12 @@ import {
getRootSectionLane,
getPermissionsWithChildren,
getPermissionsWithParents,
+ normalizeBoardLayoutRoles,
rootSectionOffsets,
widgetDefaultSizes,
+ widgetIntegrationLimits,
+ widgetIntegrationSupport,
+ widgetKindsWithOptionalIntegrations,
widgetKinds,
} from "@homarr/definitions";
import { importOldmarrAsync } from "@homarr/old-import";
@@ -52,10 +58,12 @@ import {
boardCreateSchema,
boardDuplicateSchema,
boardRenameSchema,
+ boardResetLayoutSchema,
boardSaveLayoutsSchema,
boardSavePartialSettingsSchema,
boardSavePermissionsSchema,
boardSaveSchema,
+ boardSettingsSchema,
boardSummarySchema,
} from "@homarr/validation/board";
import { byIdSchema } from "@homarr/validation/common";
@@ -65,11 +73,11 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
+import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access";
import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
} from "./board/custom-widget-placement-access";
-import { generateResponsiveGridFor } from "./board/grid-algorithm";
import { validateTimetableOptionsChangeAsync } from "./widgets/timetable";
export const boardRouter = createTRPCRouter({
@@ -322,15 +330,28 @@ export const boardRouter = createTRPCRouter({
yOffset: 0,
boardId,
});
- createBoardCollection.layouts.push({
- id: createId(),
- name: "Base",
- columnCount: input.columnCount,
- leftGutterColumnCount: 0,
- rightGutterColumnCount: 0,
- breakpoint: 0,
- boardId,
- });
+ createBoardCollection.layouts.push(
+ {
+ id: createId(),
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 0,
+ role: "mobile",
+ boardId,
+ },
+ {
+ id: createId(),
+ name: "Base",
+ columnCount: input.columnCount,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 768,
+ role: "base",
+ boardId,
+ },
+ );
await createBoardCollection.insertAllAsync(ctx.db);
@@ -387,8 +408,65 @@ export const boardRouter = createTRPCRouter({
const newBoardId = createId();
- const layoutsMap = new Map(boardLayouts.map((layout) => [layout.id, createId()]));
- const layoutsToInsert = boardLayouts.map((layout) => ({
+ const generatedMobileLayoutId = createId();
+ const generatedMobilePositions =
+ boardLayouts.length === 1 && boardLayouts[0]
+ ? getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: boardLayouts[0].id,
+ columnCount: boardLayouts[0].columnCount,
+ leftGutterColumnCount: boardLayouts[0].leftGutterColumnCount,
+ rightGutterColumnCount: boardLayouts[0].rightGutterColumnCount,
+ },
+ current: {
+ layoutId: generatedMobileLayoutId,
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ },
+ })
+ : null;
+ const normalizedBoardLayouts =
+ boardLayouts.length === 0
+ ? [
+ {
+ id: generatedMobileLayoutId,
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 0,
+ role: "mobile" as const,
+ boardId: board.id,
+ },
+ {
+ id: createId(),
+ name: "Base",
+ columnCount: 10,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 768,
+ role: "base" as const,
+ boardId: board.id,
+ },
+ ]
+ : boardLayouts.length === 1 && boardLayouts[0]
+ ? [
+ {
+ id: generatedMobileLayoutId,
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 0,
+ role: "mobile" as const,
+ boardId: board.id,
+ },
+ { ...boardLayouts[0], breakpoint: 768, role: "base" as const },
+ ]
+ : normalizeBoardLayoutRoles(boardLayouts);
+ const layoutsMap = new Map(normalizedBoardLayouts.map((layout) => [layout.id, createId()]));
+ const layoutsToInsert = normalizedBoardLayouts.map((layout) => ({
...layout,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: layoutsMap.get(layout.id)!,
@@ -405,9 +483,24 @@ export const boardRouter = createTRPCRouter({
}),
);
- const sectionLayoutsToInsert: InferInsertModel[] = boardSections.flatMap((section) =>
- section.layouts.map(
- (layoutSection): InferInsertModel => ({
+ const sectionLayoutsToInsert: InferInsertModel[] = boardSections
+ .flatMap((section) =>
+ section.layouts.map(
+ (layoutSection): InferInsertModel => ({
+ ...layoutSection,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ layoutId: layoutsMap.get(layoutSection.layoutId)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionId: sectionMap.get(layoutSection.sectionId)!,
+ parentSectionId: layoutSection.parentSectionId
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionMap.get(layoutSection.parentSectionId)!
+ : layoutSection.parentSectionId,
+ }),
+ ),
+ )
+ .concat(
+ (generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
@@ -416,10 +509,9 @@ export const boardRouter = createTRPCRouter({
parentSectionId: layoutSection.parentSectionId
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionMap.get(layoutSection.parentSectionId)!
- : layoutSection.parentSectionId,
- }),
- ),
- );
+ : null,
+ })),
+ );
const sectionCollapseStatesToInsert: InferInsertModel[] = boardSections.flatMap(
(section) =>
section.collapseStates.map(
@@ -441,9 +533,22 @@ export const boardRouter = createTRPCRouter({
}),
);
- const itemLayoutsToInsert: InferInsertModel[] = boardItems.flatMap((item) =>
- item.layouts.map(
- (layoutSection): InferInsertModel => ({
+ const itemLayoutsToInsert: InferInsertModel[] = boardItems
+ .flatMap((item) =>
+ item.layouts.map(
+ (layoutSection): InferInsertModel => ({
+ ...layoutSection,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionId: sectionMap.get(layoutSection.sectionId)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ itemId: itemMap.get(layoutSection.itemId)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ layoutId: layoutsMap.get(layoutSection.layoutId)!,
+ }),
+ ),
+ )
+ .concat(
+ (generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
@@ -451,9 +556,8 @@ export const boardRouter = createTRPCRouter({
itemId: itemMap.get(layoutSection.itemId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
- }),
- ),
- );
+ })),
+ );
// Creates a list with all integration ids the user has access to
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
@@ -694,153 +798,389 @@ export const boardRouter = createTRPCRouter({
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
- saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => {
- await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
+ getBoardSettings: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Read the editable visual and behavior settings for one board, including its current custom CSS. Requires modify permission. REQUIRED: id (board ID). Call this before proposing board settings or custom CSS changes",
+ },
+ })
+ .input(z.object({ id: z.string() }))
+ .output(boardSettingsSchema)
+ .query(async ({ input, ctx }) => {
+ const boardWhere = eq(boards.id, input.id);
+ await throwIfActionForbiddenAsync(ctx, boardWhere, "modify");
- const requiredGutterRoots = (["left", "right"] as const).filter((lane) =>
- input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0),
- );
- await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots);
-
- const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
-
- const addedLayouts = filterAddedItems(input.layouts, board.layouts);
-
- const layoutsToInsert: InferInsertModel[] = [];
- const itemSectionLayoutsToInsert: InferInsertModel[] = [];
- const sectionLayoutsToInsert: InferInsertModel[] = [];
-
- for (const addedLayout of addedLayouts) {
- const layoutId = createId();
-
- const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
- // Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
- const layoutToClone =
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- sortedLayouts.find((layout) => layout.columnCount >= addedLayout.columnCount) ?? sortedLayouts.at(-1)!;
-
- const updatedBoardLayout = getUpdatedBoardLayout(board, {
- previous: {
- layoutId: layoutToClone.id,
- columnCount: layoutToClone.columnCount,
- leftGutterColumnCount: layoutToClone.leftGutterColumnCount,
- rightGutterColumnCount: layoutToClone.rightGutterColumnCount,
+ const board = await ctx.db.query.boards.findFirst({
+ columns: {
+ id: true,
+ name: true,
+ pageTitle: true,
+ metaTitle: true,
+ logoImageUrl: true,
+ faviconImageUrl: true,
+ backgroundImageUrl: true,
+ backgroundImageAttachment: true,
+ backgroundImageRepeat: true,
+ backgroundImageSize: true,
+ primaryColor: true,
+ secondaryColor: true,
+ opacity: true,
+ customCss: true,
+ iconColor: true,
+ itemRadius: true,
+ disableStatus: true,
},
- current: {
- layoutId,
+ where: boardWhere,
+ });
+ if (!board) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
+ }
+
+ return {
+ ...board,
+ pageTitle: board.pageTitle ?? "",
+ metaTitle: board.metaTitle ?? "",
+ logoImageUrl: board.logoImageUrl ?? "",
+ faviconImageUrl: board.faviconImageUrl ?? "",
+ backgroundImageUrl: board.backgroundImageUrl ?? "",
+ customCss: board.customCss ?? "",
+ iconColor: board.iconColor ?? "",
+ };
+ }),
+ saveLayouts: protectedProcedure
+ .input(boardSaveLayoutsSchema)
+ .output(boardSaveLayoutsSchema.shape.layouts)
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
+
+ const requiredGutterRoots = (["left", "right"] as const).filter((lane) =>
+ input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0),
+ );
+ await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots);
+
+ const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
+ const existingLayoutsById = new Map(board.layouts.map((layout) => [layout.id, layout]));
+ const addedLayouts = filterAddedItems(input.layouts, board.layouts);
+ const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
+
+ if (addedLayouts.some((layout) => layout.role !== "custom")) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "New layouts must use the custom role" });
+ }
+ if (removedLayouts.some((layout) => layout.role !== "custom")) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Mobile and Base layouts cannot be removed" });
+ }
+ if (
+ input.layouts.some((layout) => {
+ const existingLayout = existingLayoutsById.get(layout.id);
+ return existingLayout && existingLayout.role !== layout.role;
+ })
+ ) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Layout roles cannot be changed" });
+ }
+
+ const baseLayout = board.layouts.find((layout) => layout.role === "base");
+ if (!baseLayout) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" });
+ }
+
+ const layoutsToInsert: InferInsertModel[] = [];
+ const itemSectionLayoutsToInsert: InferInsertModel[] = [];
+ const sectionLayoutsToInsert: InferInsertModel[] = [];
+ const savedLayoutIds = new Map();
+ const requestedBaseLayout = input.layouts.find((layout) => layout.id === baseLayout.id);
+ const resizedBaseLayout =
+ requestedBaseLayout &&
+ (requestedBaseLayout.columnCount !== baseLayout.columnCount ||
+ requestedBaseLayout.leftGutterColumnCount !== baseLayout.leftGutterColumnCount ||
+ requestedBaseLayout.rightGutterColumnCount !== baseLayout.rightGutterColumnCount)
+ ? getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: baseLayout.id,
+ columnCount: baseLayout.columnCount,
+ leftGutterColumnCount: baseLayout.leftGutterColumnCount,
+ rightGutterColumnCount: baseLayout.rightGutterColumnCount,
+ },
+ current: {
+ layoutId: baseLayout.id,
+ columnCount: requestedBaseLayout.columnCount,
+ leftGutterColumnCount: requestedBaseLayout.leftGutterColumnCount,
+ rightGutterColumnCount: requestedBaseLayout.rightGutterColumnCount,
+ },
+ })
+ : null;
+ const baseSourceElements = resizedBaseLayout ? getElementsForProjectedLayout(resizedBaseLayout) : undefined;
+ const baseSourceGeometry = {
+ layoutId: baseLayout.id,
+ columnCount: requestedBaseLayout?.columnCount ?? baseLayout.columnCount,
+ leftGutterColumnCount: requestedBaseLayout?.leftGutterColumnCount ?? baseLayout.leftGutterColumnCount,
+ rightGutterColumnCount: requestedBaseLayout?.rightGutterColumnCount ?? baseLayout.rightGutterColumnCount,
+ };
+
+ for (const addedLayout of addedLayouts) {
+ const layoutId = createId();
+ savedLayoutIds.set(addedLayout.id, layoutId);
+ layoutsToInsert.push({
+ id: layoutId,
+ name: addedLayout.name,
columnCount: addedLayout.columnCount,
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
+ breakpoint: addedLayout.breakpoint,
+ role: "custom",
+ boardId: board.id,
+ });
+
+ const projectedLayout = getUpdatedBoardLayout(board, {
+ previous: {
+ ...baseSourceGeometry,
+ elements: baseSourceElements,
+ },
+ current: {
+ layoutId,
+ columnCount: addedLayout.columnCount,
+ leftGutterColumnCount: addedLayout.leftGutterColumnCount,
+ rightGutterColumnCount: addedLayout.rightGutterColumnCount,
+ },
+ });
+ itemSectionLayoutsToInsert.push(...projectedLayout.itemSectionLayouts);
+ sectionLayoutsToInsert.push(...projectedLayout.sectionLayouts);
+ }
+
+ const itemSectionLayoutsToUpdate: InferInsertModel[] = [];
+ const sectionLayoutsToUpdate: InferInsertModel[] = [];
+ const layoutsToUpdate = filterUpdatedItems(input.layouts, board.layouts);
+
+ for (const updatedLayout of layoutsToUpdate) {
+ const dbLayout = existingLayoutsById.get(updatedLayout.id);
+ if (
+ !dbLayout ||
+ (dbLayout.columnCount === updatedLayout.columnCount &&
+ dbLayout.leftGutterColumnCount === updatedLayout.leftGutterColumnCount &&
+ dbLayout.rightGutterColumnCount === updatedLayout.rightGutterColumnCount)
+ )
+ continue;
+
+ const projectedLayout =
+ updatedLayout.role === "base" && resizedBaseLayout
+ ? resizedBaseLayout
+ : getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: dbLayout.id,
+ columnCount: dbLayout.columnCount,
+ leftGutterColumnCount: dbLayout.leftGutterColumnCount,
+ rightGutterColumnCount: dbLayout.rightGutterColumnCount,
+ },
+ current: {
+ layoutId: dbLayout.id,
+ columnCount: updatedLayout.columnCount,
+ leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
+ rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
+ },
+ });
+ itemSectionLayoutsToUpdate.push(...projectedLayout.itemSectionLayouts);
+ sectionLayoutsToUpdate.push(...projectedLayout.sectionLayouts);
+ }
+
+ const removedLayoutIds = removedLayouts.map((layout) => layout.id);
+
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (transaction) => {
+ if (layoutsToInsert.length > 0) await transaction.insert(schema.layouts).values(layoutsToInsert);
+ if (itemSectionLayoutsToInsert.length > 0) {
+ await transaction.insert(schema.itemLayouts).values(itemSectionLayoutsToInsert);
+ }
+ if (sectionLayoutsToInsert.length > 0) {
+ await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
+ }
+
+ for (const itemLayout of itemSectionLayoutsToUpdate) {
+ await transaction
+ .update(schema.itemLayouts)
+ .set({
+ height: itemLayout.height,
+ width: itemLayout.width,
+ xOffset: itemLayout.xOffset,
+ yOffset: itemLayout.yOffset,
+ sectionId: itemLayout.sectionId,
+ })
+ .where(
+ and(
+ eq(schema.itemLayouts.itemId, itemLayout.itemId),
+ eq(schema.itemLayouts.layoutId, itemLayout.layoutId),
+ ),
+ );
+ }
+ for (const sectionLayout of sectionLayoutsToUpdate) {
+ await transaction
+ .update(schema.sectionLayouts)
+ .set({
+ height: sectionLayout.height,
+ width: sectionLayout.width,
+ xOffset: sectionLayout.xOffset,
+ yOffset: sectionLayout.yOffset,
+ parentSectionId: sectionLayout.parentSectionId,
+ })
+ .where(
+ and(
+ eq(schema.sectionLayouts.sectionId, sectionLayout.sectionId),
+ eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
+ ),
+ );
+ }
+ for (const layout of layoutsToUpdate) {
+ await transaction
+ .update(schema.layouts)
+ .set({
+ name: layout.name,
+ columnCount: layout.columnCount,
+ leftGutterColumnCount: layout.leftGutterColumnCount,
+ rightGutterColumnCount: layout.rightGutterColumnCount,
+ breakpoint: layout.breakpoint,
+ })
+ .where(eq(schema.layouts.id, layout.id));
+ }
+ if (removedLayoutIds.length > 0) {
+ await transaction.delete(schema.layouts).where(inArray(schema.layouts.id, removedLayoutIds));
+ }
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ if (layoutsToInsert.length > 0) transaction.insert(layouts).values(layoutsToInsert).run();
+ if (itemSectionLayoutsToInsert.length > 0) {
+ transaction.insert(itemLayouts).values(itemSectionLayoutsToInsert).run();
+ }
+ if (sectionLayoutsToInsert.length > 0) {
+ transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
+ }
+
+ for (const itemLayout of itemSectionLayoutsToUpdate) {
+ transaction
+ .update(itemLayouts)
+ .set({
+ height: itemLayout.height,
+ width: itemLayout.width,
+ xOffset: itemLayout.xOffset,
+ yOffset: itemLayout.yOffset,
+ sectionId: itemLayout.sectionId,
+ })
+ .where(and(eq(itemLayouts.itemId, itemLayout.itemId), eq(itemLayouts.layoutId, itemLayout.layoutId)))
+ .run();
+ }
+ for (const sectionLayout of sectionLayoutsToUpdate) {
+ transaction
+ .update(sectionLayouts)
+ .set({
+ height: sectionLayout.height,
+ width: sectionLayout.width,
+ xOffset: sectionLayout.xOffset,
+ yOffset: sectionLayout.yOffset,
+ parentSectionId: sectionLayout.parentSectionId,
+ })
+ .where(
+ and(
+ eq(sectionLayouts.sectionId, sectionLayout.sectionId),
+ eq(sectionLayouts.layoutId, sectionLayout.layoutId),
+ ),
+ )
+ .run();
+ }
+ for (const layout of layoutsToUpdate) {
+ transaction
+ .update(layouts)
+ .set({
+ name: layout.name,
+ columnCount: layout.columnCount,
+ leftGutterColumnCount: layout.leftGutterColumnCount,
+ rightGutterColumnCount: layout.rightGutterColumnCount,
+ breakpoint: layout.breakpoint,
+ })
+ .where(eq(layouts.id, layout.id))
+ .run();
+ }
+ if (removedLayoutIds.length > 0)
+ transaction.delete(layouts).where(inArray(layouts.id, removedLayoutIds)).run();
+ });
},
});
- layoutsToInsert.push({
- id: layoutId,
- name: addedLayout.name,
- columnCount: addedLayout.columnCount,
- leftGutterColumnCount: addedLayout.leftGutterColumnCount,
- rightGutterColumnCount: addedLayout.rightGutterColumnCount,
- breakpoint: addedLayout.breakpoint,
- boardId: board.id,
- });
+ return input.layouts
+ .map((layout) => ({
+ ...layout,
+ id: savedLayoutIds.get(layout.id) ?? layout.id,
+ role: existingLayoutsById.get(layout.id)?.role ?? "custom",
+ }))
+ .toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint);
+ }),
+ resetLayout: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Reset a board's Mobile or custom layout from its Base layout while preserving the target layout settings. Requires modify permission. REQUIRED: boardId (board ID), layoutId (non-Base layout ID)",
+ },
+ })
+ .input(boardResetLayoutSchema)
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
- itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
- sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
- }
+ const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.boardId), ctx.session.user.id);
+ const targetLayout = board.layouts.find((layout) => layout.id === input.layoutId);
+ const baseLayout = board.layouts.find((layout) => layout.role === "base");
- if (layoutsToInsert.length > 0) {
- await ctx.db.insert(layouts).values(layoutsToInsert);
- }
-
- if (itemSectionLayoutsToInsert.length > 0) {
- await ctx.db.insert(itemLayouts).values(itemSectionLayoutsToInsert);
- }
-
- if (sectionLayoutsToInsert.length > 0) {
- await ctx.db.insert(sectionLayouts).values(sectionLayoutsToInsert);
- }
-
- const updatedLayouts = filterUpdatedItems(input.layouts, board.layouts);
- for (const updatedLayout of updatedLayouts) {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id)!;
- let updatedBoardLayout: ReturnType | undefined;
-
- if (
- dbLayout.columnCount !== updatedLayout.columnCount ||
- dbLayout.leftGutterColumnCount !== updatedLayout.leftGutterColumnCount ||
- dbLayout.rightGutterColumnCount !== updatedLayout.rightGutterColumnCount
- ) {
- updatedBoardLayout = getUpdatedBoardLayout(board, {
- previous: {
- layoutId: dbLayout.id,
- columnCount: dbLayout.columnCount,
- leftGutterColumnCount: dbLayout.leftGutterColumnCount,
- rightGutterColumnCount: dbLayout.rightGutterColumnCount,
- },
- current: {
- layoutId: dbLayout.id,
- columnCount: updatedLayout.columnCount,
- leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
- rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
- },
- });
-
- for (const itemSectionLayout of updatedBoardLayout.itemSectionLayouts) {
- await ctx.db
- .update(itemLayouts)
- .set({
- height: itemSectionLayout.height,
- width: itemSectionLayout.width,
- xOffset: itemSectionLayout.xOffset,
- yOffset: itemSectionLayout.yOffset,
- sectionId: itemSectionLayout.sectionId,
- })
- .where(
- and(
- eq(itemLayouts.itemId, itemSectionLayout.itemId),
- eq(itemLayouts.layoutId, itemSectionLayout.layoutId),
- ),
- );
- }
-
- for (const sectionLayout of updatedBoardLayout.sectionLayouts) {
- await ctx.db
- .update(sectionLayouts)
- .set({
- height: sectionLayout.height,
- width: sectionLayout.width,
- xOffset: sectionLayout.xOffset,
- yOffset: sectionLayout.yOffset,
- parentSectionId: sectionLayout.parentSectionId,
- })
- .where(
- and(
- eq(sectionLayouts.sectionId, sectionLayout.sectionId),
- eq(sectionLayouts.layoutId, sectionLayout.layoutId),
- ),
- );
- }
+ if (!targetLayout) throw new TRPCError({ code: "NOT_FOUND", message: "Layout not found" });
+ if (!baseLayout) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" });
+ }
+ if (targetLayout.role === "base") {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "The Base layout cannot be reset" });
}
- await ctx.db
- .update(layouts)
- .set({
- name: updatedLayout.name,
- columnCount: updatedLayout.columnCount,
- leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
- rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
- breakpoint: updatedLayout.breakpoint,
- })
- .where(eq(layouts.id, updatedLayout.id));
- }
+ const projectedLayout = getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: baseLayout.id,
+ columnCount: baseLayout.columnCount,
+ leftGutterColumnCount: baseLayout.leftGutterColumnCount,
+ rightGutterColumnCount: baseLayout.rightGutterColumnCount,
+ },
+ current: {
+ layoutId: targetLayout.id,
+ columnCount: targetLayout.columnCount,
+ leftGutterColumnCount: targetLayout.leftGutterColumnCount,
+ rightGutterColumnCount: targetLayout.rightGutterColumnCount,
+ },
+ });
- const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
- const removedLayoutIds = removedLayouts.map((layout) => layout.id);
- if (removedLayoutIds.length > 0) {
- await ctx.db.delete(layouts).where(inArray(layouts.id, removedLayoutIds));
- }
- }),
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (transaction) => {
+ await transaction.delete(schema.itemLayouts).where(eq(schema.itemLayouts.layoutId, targetLayout.id));
+ await transaction.delete(schema.sectionLayouts).where(eq(schema.sectionLayouts.layoutId, targetLayout.id));
+ if (projectedLayout.itemSectionLayouts.length > 0) {
+ await transaction.insert(schema.itemLayouts).values(projectedLayout.itemSectionLayouts);
+ }
+ if (projectedLayout.sectionLayouts.length > 0) {
+ await transaction.insert(schema.sectionLayouts).values(projectedLayout.sectionLayouts);
+ }
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ transaction.delete(itemLayouts).where(eq(itemLayouts.layoutId, targetLayout.id)).run();
+ transaction.delete(sectionLayouts).where(eq(sectionLayouts.layoutId, targetLayout.id)).run();
+ if (projectedLayout.itemSectionLayouts.length > 0) {
+ transaction.insert(itemLayouts).values(projectedLayout.itemSectionLayouts).run();
+ }
+ if (projectedLayout.sectionLayouts.length > 0) {
+ transaction.insert(sectionLayouts).values(projectedLayout.sectionLayouts).run();
+ }
+ });
+ },
+ });
+ }),
savePartialBoardSettings: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
@@ -1476,7 +1816,7 @@ export const boardRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
- "Add a widget/app item to a board. Automatically places it in the first empty section at the next free grid position. Provide boardId (from board_getAllBoards), kind (widget type like 'app', 'weather', etc.), optional options map, and optional integrationIds array. Returns { itemId }",
+ "Add a widget/app item to a board after configure_widget has reviewed it. Automatically places it in the first empty section at the next free grid position. Use the configure_widget result's boardId, kind, options, and integrationIds exactly. Integration IDs must be accessible to the current user. To create a formatted dashboard note, configure kind 'notebook' with options { content: Tiptap-compatible HTML, showToolbar: boolean, allowReadOnlyCheck: boolean }. Returns { itemId }",
},
})
.input(addItemToBoardSchema)
@@ -1493,9 +1833,28 @@ export const boardRouter = createTRPCRouter({
await validateTimetableOptionsChangeAsync(input.options);
}
+ const supportedIntegrations = widgetIntegrationSupport[input.kind];
+ const integrationLimit = widgetIntegrationLimits[input.kind] ?? Infinity;
+ if (input.integrationIds.length > integrationLimit) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `${input.kind} supports at most ${integrationLimit} integration${integrationLimit === 1 ? "" : "s"}`,
+ });
+ }
+ if (supportedIntegrations === undefined && input.integrationIds.length > 0) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} does not support integrations` });
+ }
+ if (
+ supportedIntegrations !== undefined &&
+ input.integrationIds.length === 0 &&
+ !widgetKindsWithOptionalIntegrations.has(input.kind)
+ ) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} requires an integration` });
+ }
+
if (input.integrationIds.length > 0) {
const existing = await ctx.db.query.integrations.findMany({
- columns: { id: true },
+ columns: { id: true, kind: true },
where: inArray(integrations.id, input.integrationIds),
});
const validIds = new Set(existing.map((row) => row.id));
@@ -1503,6 +1862,20 @@ export const boardRouter = createTRPCRouter({
if (invalid.length > 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
}
+
+ await Promise.all(
+ input.integrationIds.map((integrationId) =>
+ throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
+ ),
+ );
+
+ const incompatible = existing.filter((integration) => !supportedIntegrations?.includes(integration.kind));
+ if (incompatible.length > 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `${input.kind} does not support integration kind${incompatible.length === 1 ? "" : "s"}: ${incompatible.map((integration) => integration.kind).join(", ")}`,
+ });
+ }
}
const board = await ctx.db.query.boards.findFirst({
@@ -1556,13 +1929,14 @@ export const boardRouter = createTRPCRouter({
}
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
+ const sizeForLayout = { ...defaultSize, width: Math.min(layout.columnCount, defaultSize.width) };
const fitsAt = (x: number, y: number) => {
- if (x + defaultSize.width > layout.columnCount) return false;
- for (let dy = 0; dy < defaultSize.height; dy++) {
+ if (x + sizeForLayout.width > layout.columnCount) return false;
+ for (let dy = 0; dy < sizeForLayout.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
- for (let dx = 0; dx < defaultSize.width; dx++) {
+ for (let dx = 0; dx < sizeForLayout.width; dx++) {
if (row[x + dx]) return false;
}
}
@@ -1580,8 +1954,8 @@ export const boardRouter = createTRPCRouter({
layoutId: layout.id,
xOffset: x,
yOffset: y,
- width: defaultSize.width,
- height: defaultSize.height,
+ width: sizeForLayout.width,
+ height: sizeForLayout.height,
});
placed = true;
}
@@ -1691,14 +2065,43 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
}
};
+interface BoardForLayoutProjection {
+ id: string;
+ items: Array<{
+ id: string;
+ layouts: Array<{
+ layoutId: string;
+ sectionId: string;
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ }>;
+ }>;
+ sections: Array<{
+ id: string;
+ kind: string;
+ xOffset?: number | null;
+ yOffset?: number | null;
+ layouts?: Array<{
+ layoutId: string;
+ parentSectionId: string | null;
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ }>;
+ }>;
+}
+
const getUpdatedBoardLayout = (
- board: Awaited>,
+ board: BoardForLayoutProjection,
options: {
- previous: BoardLayoutGeometry;
+ previous: BoardLayoutGeometry & { elements?: GridAlgorithmItem[] };
current: BoardLayoutGeometry;
},
) => {
- const elements = getElementsForLayout(board, options.previous.layoutId);
+ const elements = options.previous.elements ?? getElementsForLayout(board, options.previous.layoutId);
const emptyRoots = board.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.yOffset ?? 0) - (second.yOffset ?? 0) || first.id.localeCompare(second.id));
@@ -1734,7 +2137,12 @@ const getUpdatedBoardLayout = (
const root = rootByLane.get(lane);
const width = getBoardLaneColumnCount(options.current, lane);
if (!root || width === 0) return [];
- const sourceLanes = boardLanes.filter((sourceLane) => targetLaneBySourceLane.get(sourceLane) === lane);
+ const sourceLanes = boardLanes.filter(
+ (sourceLane) =>
+ rootByLane.has(sourceLane) &&
+ getBoardLaneColumnCount(options.previous, sourceLane) > 0 &&
+ targetLaneBySourceLane.get(sourceLane) === lane,
+ );
const previousWidth =
sourceLanes.length === 1
? getBoardLaneColumnCount(options.previous, sourceLanes[0] ?? "main")
@@ -1793,6 +2201,35 @@ const getUpdatedBoardLayout = (
};
};
+const getElementsForProjectedLayout = (
+ projectedLayout: ReturnType,
+): GridAlgorithmItem[] => [
+ ...projectedLayout.itemSectionLayouts.map((layout) => ({
+ id: layout.itemId,
+ type: "item" as const,
+ height: layout.height,
+ width: layout.width,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ sectionId: layout.sectionId,
+ })),
+ ...projectedLayout.sectionLayouts.flatMap((layout) =>
+ layout.parentSectionId
+ ? [
+ {
+ id: layout.sectionId,
+ type: "section" as const,
+ height: layout.height,
+ width: layout.width,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ sectionId: layout.parentSectionId,
+ },
+ ]
+ : [],
+ ),
+];
+
interface BoardLayoutGeometry {
layoutId: string;
columnCount: number;
@@ -1873,43 +2310,54 @@ const ensureGutterRootSectionsAsync = async (db: Database, boardId: string, lane
});
};
-const getElementsForLayout = (board: Awaited>, layoutId: string) => {
+const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string) => {
const sectionElements = board.sections
.filter((section) => section.kind === "container")
- .map((section) => {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const clonedLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layoutId)!;
+ .flatMap((section) => {
+ const clonedLayout = section.layouts?.find((sectionLayout) => sectionLayout.layoutId === layoutId);
+ if (!clonedLayout?.parentSectionId) return [];
- return {
- id: section.id,
- type: "section" as const,
+ return [
+ {
+ id: section.id,
+ type: "section" as const,
+ height: clonedLayout.height,
+ width: clonedLayout.width,
+ xOffset: clonedLayout.xOffset,
+ yOffset: clonedLayout.yOffset,
+ sectionId: clonedLayout.parentSectionId,
+ },
+ ];
+ });
+
+ const itemElements = board.items.flatMap((item) => {
+ const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId);
+ if (!clonedLayout) return [];
+
+ return [
+ {
+ id: item.id,
+ type: "item" as const,
height: clonedLayout.height,
width: clonedLayout.width,
xOffset: clonedLayout.xOffset,
yOffset: clonedLayout.yOffset,
- sectionId: clonedLayout.parentSectionId,
- };
- });
-
- const itemElements = board.items.map((item) => {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId)!;
-
- return {
- id: item.id,
- type: "item" as const,
- height: clonedLayout.height,
- width: clonedLayout.width,
- xOffset: clonedLayout.xOffset,
- yOffset: clonedLayout.yOffset,
- sectionId: clonedLayout.sectionId,
- };
+ sectionId: clonedLayout.sectionId,
+ },
+ ];
});
return [...itemElements, ...sectionElements];
};
-const getFullBoardWithWhereAsync = async (db: Database, where: SQL, userId: string | null) => {
+const protectedLayoutRepairPromises = new Map>();
+
+const getFullBoardWithWhereAsync = async (
+ db: Database,
+ where: SQL,
+ userId: string | null,
+ repairProtectedLayouts = true,
+) => {
const groupPermissionWhere = userId
? inArray(
boardGroupPermissions.groupId,
@@ -1965,6 +2413,18 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use
});
}
+ if (repairProtectedLayouts && boardLayoutsNeedRepair(board.layouts)) {
+ let repairPromise = protectedLayoutRepairPromises.get(board.id);
+ if (!repairPromise) {
+ repairPromise = seedProtectedBoardLayoutsAsync(db, board.id).finally(() => {
+ protectedLayoutRepairPromises.delete(board.id);
+ });
+ protectedLayoutRepairPromises.set(board.id, repairPromise);
+ }
+ await repairPromise;
+ return getFullBoardWithWhereAsync(db, where, userId, false);
+ }
+
const { sections, items, layouts, ...otherBoardProperties } = board;
return {
@@ -2010,6 +2470,23 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use
};
};
+export const boardLayoutsNeedRepair = (
+ boardLayouts: Array<{ id: string; breakpoint: number; role: "mobile" | "base" | "custom" }>,
+) => {
+ const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
+ const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
+ const baseLayout = baseLayouts.at(0);
+
+ return (
+ mobileLayouts.length !== 1 ||
+ baseLayouts.length !== 1 ||
+ mobileLayouts.at(0)?.breakpoint !== 0 ||
+ !baseLayout ||
+ boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) ||
+ new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
+ );
+};
+
const forKind = (kind: T) =>
z.object({
kind: z.literal(kind),
diff --git a/packages/api/src/router/board/grid-algorithm.ts b/packages/api/src/router/board/grid-algorithm.ts
deleted file mode 100644
index 78d7ab7fc..000000000
--- a/packages/api/src/router/board/grid-algorithm.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-export interface GridAlgorithmItem {
- id: string;
- type: "item" | "section";
- width: number;
- height: number;
- xOffset: number;
- yOffset: number;
- sectionId: string;
-}
-
-interface GridAlgorithmInput {
- items: GridAlgorithmItem[];
- width: number;
- previousWidth: number;
- sectionId: string;
-}
-
-interface GridAlgorithmOutput {
- height: number;
- items: GridAlgorithmItem[];
-}
-
-export const generateResponsiveGridFor = ({
- items,
- previousWidth,
- width,
- sectionId,
-}: GridAlgorithmInput): GridAlgorithmOutput => {
- const itemsOfCurrentSection = items
- .filter((item) => item.sectionId === sectionId)
- .toSorted((itemA, itemB) =>
- itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
- );
- const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
-
- if (itemsOfCurrentSection.length === 0) {
- return {
- height: 0,
- items: [],
- };
- }
-
- const newItems: GridAlgorithmItem[] = [];
-
- // A container must remain tall enough for its recursively reflowed contents.
- const containerHeightMap = new Map();
- const containersOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
- for (const container of containersOfCurrentSection) {
- const result = generateResponsiveGridFor({
- items,
- previousWidth: container.previousWidth,
- width: container.width,
- sectionId: container.id,
- });
- newItems.push(...result.items);
- containerHeightMap.set(container.id, result.height);
- }
-
- // Return same positions for items in the current section
- if (width >= previousWidth) {
- return {
- height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
- items: newItems.concat(normalizedItems),
- };
- }
-
- const occupied2d: boolean[][] = [];
-
- for (const item of normalizedItems) {
- const itemWithHeight = {
- ...item,
- height: item.type === "section" ? Math.max(containerHeightMap.get(item.id) ?? 1, item.height) : item.height,
- };
- const position = nextFreeSpot(occupied2d, itemWithHeight, width);
- if (!position) throw new Error("No free spot available");
-
- addItemToOccupied(occupied2d, itemWithHeight, position, width);
- newItems.push({
- ...itemWithHeight,
- xOffset: position.x,
- yOffset: position.y,
- });
- }
-
- return {
- height: occupied2d.length,
- items: newItems,
- };
-};
-
-/**
- * Reduces the width of the items to fit the new column count.
- * @param items items to normalize
- * @param columnCount new column count
- */
-const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => {
- return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
-};
-
-/**
- * Adds the item to the occupied spots.
- * @param occupied2d array of occupied spots
- * @param item item to place
- * @param position position to place the item
- */
-const addItemToOccupied = (
- occupied2d: boolean[][],
- item: GridAlgorithmItem,
- position: { x: number; y: number },
- columnCount: number,
-) => {
- for (let yOffset = 0; yOffset < item.height; yOffset++) {
- let row = occupied2d[position.y + yOffset];
- if (!row) {
- addRow(occupied2d, columnCount);
- // After adding it, it must exist
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- row = occupied2d[position.y + yOffset]!;
- }
-
- for (let xOffset = 0; xOffset < item.width; xOffset++) {
- row[position.x + xOffset] = true;
- }
- }
-};
-
-/**
- * Adds a new row to the grid.
- * @param occupied2d array of occupied spots
- * @param columnCount column count of section
- */
-const addRow = (occupied2d: boolean[][], columnCount: number) => {
- occupied2d.push(new Array(columnCount).fill(false));
-};
-
-/**
- * Searches for the next free spot in the grid.
- * @param occupied2d array of occupied spots
- * @param item item to place
- * @param columnCount column count of section
- * @returns the position of the next free spot or null if no spot is available
- */
-const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
- for (let offsetY = 0; offsetY < 99999; offsetY++) {
- for (let offsetX = 0; offsetX < columnCount; offsetX++) {
- if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
- return { x: offsetX, y: offsetY };
- }
- }
- }
-
- return null;
-};
-
-/**
- * Check if the item fits into the grid horizontally.
- * @param columnCount available width
- * @param item item to place
- * @param offsetX current x position
- * @returns true if the item fits horizontally
- */
-const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => {
- return offsetX + item.width <= columnCount;
-};
-
-/**
- * Check if the spot is free.
- * @param occupied2d array of occupied spots
- * @param item item to place
- * @param position position to check
- * @returns true if the spot is free
- */
-const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
- for (let yOffset = 0; yOffset < item.height; yOffset++) {
- const row = occupied2d[position.y + yOffset];
- if (!row) return true; // Empty row is free
-
- for (let xOffset = 0; xOffset < item.width; xOffset++) {
- if (row[position.x + xOffset]) {
- return false;
- }
- }
- }
-
- return true;
-};
diff --git a/packages/api/src/router/board/test/grid-algorithm.spec.ts b/packages/api/src/router/board/test/grid-algorithm.spec.ts
index 13c66759c..ca028634b 100644
--- a/packages/api/src/router/board/test/grid-algorithm.spec.ts
+++ b/packages/api/src/router/board/test/grid-algorithm.spec.ts
@@ -1,8 +1,8 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
-import type { GridAlgorithmItem } from "../grid-algorithm";
-import { generateResponsiveGridFor } from "../grid-algorithm";
+import { generateResponsiveGridFor } from "@homarr/common";
+import type { GridAlgorithmItem } from "@homarr/common";
const ROOT_SECTION_ID = "section";
@@ -78,6 +78,26 @@ describe("Grid Algorithm", () => {
);
},
);
+
+ test("stops projecting a corrupted recursive section graph", () => {
+ const recursiveSection = algoItem({ id: "recursive", type: "section", width: 3 });
+ const duplicateRecursiveLayout = algoItem({
+ id: "recursive",
+ type: "section",
+ width: 3,
+ sectionId: "recursive",
+ });
+
+ const result = generateResponsiveGridFor({
+ items: [recursiveSection, duplicateRecursiveLayout],
+ width: 3,
+ previousWidth: 12,
+ sectionId: ROOT_SECTION_ID,
+ });
+
+ expect(result.items).toContainEqual(expect.objectContaining({ id: "recursive", sectionId: ROOT_SECTION_ID }));
+ expect(result.items).toHaveLength(2);
+ });
});
const algoItem = (item: Partial): GridAlgorithmItem => ({
diff --git a/packages/api/src/router/custom-widget/authoring-validation.ts b/packages/api/src/router/custom-widget/authoring-validation.ts
new file mode 100644
index 000000000..43025d103
--- /dev/null
+++ b/packages/api/src/router/custom-widget/authoring-validation.ts
@@ -0,0 +1,22 @@
+import { TRPCError } from "@trpc/server";
+import { z } from "zod/v4";
+
+/**
+ * Authoring inputs that use templateLines can only validate the joined JSX after the tRPC input
+ * parser has run. Preserve those Zod issues as a client-safe BAD_REQUEST instead of letting tRPC
+ * turn them into a generic internal error.
+ */
+export function parseCustomWidgetAuthoringInput(parse: () => T): T {
+ try {
+ return parse();
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "The custom widget authoring input is invalid",
+ cause: error,
+ });
+ }
+ throw error;
+ }
+}
diff --git a/packages/api/src/router/custom-widget/custom-widget-router.ts b/packages/api/src/router/custom-widget/custom-widget-router.ts
index 83c6bb080..ea41d17eb 100644
--- a/packages/api/src/router/custom-widget/custom-widget-router.ts
+++ b/packages/api/src/router/custom-widget/custom-widget-router.ts
@@ -10,9 +10,12 @@ import {
customWidgetCreateSchema,
customWidgetDefinitionSchema,
customWidgetUpdateSchema,
+ normalizeCustomWidgetAuthoringDefinition,
+ normalizeCustomWidgetAuthoringUpdate,
} from "@homarr/custom-widgets/core";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
+import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { managementQueryProcedures } from "./management-queries";
import { metadataProcedures } from "./metadata-procedures";
@@ -38,20 +41,43 @@ export const customWidgetRouter = createTRPCRouter({
create: permissionRequiredProcedure
.requiresPermission("admin")
- .meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Create a Custom JSX widget only after customWidget_validate, customWidget_previewCreate, and every returned preview query succeed. Prefer templateLines for multiline JSX. Returns a client-navigable edit link.",
+ },
+ })
.input(customWidgetCreateSchema)
.mutation(async ({ ctx, input }) => {
const { secrets, ...candidate } = input;
- const definition = customWidgetDefinitionSchema.parse(candidate);
+ const definition = parseCustomWidgetAuthoringInput(() => normalizeCustomWidgetAuthoringDefinition(candidate));
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
logger.info("Created custom widget definition", { id, name: definition.name });
- return { id };
+ return {
+ id,
+ managementPath: `/manage/custom-widgets/edit/${id}`,
+ nextAction: {
+ type: "place-custom-widget" as const,
+ widgetKind: "customApi" as const,
+ options: { definitionId: id },
+ whenTargetIsKnown: "Call configure_widget now with the requested board and these exact widget options.",
+ whenTargetIsUnknown:
+ "Call ask_user now with 'Place on a board' and 'Leave unplaced'. Never ask this choice in prose.",
+ },
+ };
}),
update: permissionRequiredProcedure
.requiresPermission("admin")
- .meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Update one Custom JSX widget. Prefer templateLines for multiline JSX changes. Returns a client-navigable edit link.",
+ },
+ })
.input(customWidgetUpdateSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
@@ -60,8 +86,11 @@ export const customWidgetRouter = createTRPCRouter({
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
const current = parseStoredCustomWidgetDefinition(existing);
- const { id, secrets, ...changes } = input;
- const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes });
+ const { id, secrets, ...authoringChanges } = input;
+ const definition = parseCustomWidgetAuthoringInput(() => {
+ const changes = normalizeCustomWidgetAuthoringUpdate(authoringChanges);
+ return customWidgetDefinitionSchema.parse({ ...current, ...changes });
+ });
if (secrets) assertSecretSources(definition.sources, secrets);
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
const secretRows = secrets?.map((secret) => ({
@@ -178,6 +207,7 @@ export const customWidgetRouter = createTRPCRouter({
},
});
logger.info("Updated custom widget definition", { id });
+ return { id, managementPath: `/manage/custom-widgets/edit/${id}` };
}),
...secretProcedures,
diff --git a/packages/api/src/router/custom-widget/management-queries.ts b/packages/api/src/router/custom-widget/management-queries.ts
index ef4022e7d..7b41c1eac 100644
--- a/packages/api/src/router/custom-widget/management-queries.ts
+++ b/packages/api/src/router/custom-widget/management-queries.ts
@@ -65,6 +65,7 @@ export const managementQueryProcedures = {
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
return {
id: definition.id,
+ managementPath: "/manage/custom-widgets",
version: 1 as const,
prompt: buildLegacyCustomWidgetMigrationPrompt(
definition,
@@ -85,6 +86,7 @@ export const managementQueryProcedures = {
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
return {
id: definition.id,
+ managementPath: `/manage/custom-widgets/edit/${definition.id}`,
...parseStoredCustomWidgetDefinition(definition),
enabled: definition.enabled,
createdAt: definition.createdAt,
diff --git a/packages/api/src/router/custom-widget/management-query-mappers.ts b/packages/api/src/router/custom-widget/management-query-mappers.ts
index 7f1dba647..90cd96f44 100644
--- a/packages/api/src/router/custom-widget/management-query-mappers.ts
+++ b/packages/api/src/router/custom-widget/management-query-mappers.ts
@@ -26,6 +26,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
});
return {
id: definition.id,
+ managementPath: `/manage/custom-widgets/edit/${definition.id}`,
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
@@ -45,6 +46,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
id: definition.id,
+ managementPath: `/manage/custom-widgets/edit/${definition.id}`,
name: widget.name,
description: widget.description,
iconUrl: widget.iconUrl,
@@ -71,6 +73,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
export function mapLegacyCustomWidgetListItem(definition: LegacyDefinition) {
return {
id: definition.id,
+ managementPath: "/manage/custom-widgets",
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
diff --git a/packages/api/src/router/custom-widget/metadata-procedures.ts b/packages/api/src/router/custom-widget/metadata-procedures.ts
index 3cfd4391b..3727d7fe1 100644
--- a/packages/api/src/router/custom-widget/metadata-procedures.ts
+++ b/packages/api/src/router/custom-widget/metadata-procedures.ts
@@ -1,8 +1,19 @@
+import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
-import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
-import { customWidgetImportSchema, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
+import {
+ getCustomWidgetComponent,
+ getCustomWidgetComponentCatalog,
+ getCustomWidgetExample,
+ getCustomWidgetSharedProps,
+ getCustomWidgetSkill,
+} from "@homarr/custom-widgets/authoring-resources";
+import {
+ customWidgetAuthoringDefinitionSchema,
+ getCustomWidgetJsonSchema,
+ normalizeCustomWidgetAuthoringDefinition,
+} from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
@@ -28,25 +39,133 @@ export const metadataProcedures = {
getSkill: permissionRequiredProcedure
.requiresPermission("admin")
- .meta({ mcp: { enabled: true, description: "Get the portable Homarr Custom Widget skill." } })
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Load the complete Homarr Custom Widget authoring skill in one response, including SKILL.md and every bundled reference. Call this before authoring, repairing, validating, previewing, or creating a custom widget.",
+ },
+ })
.query(() => getCustomWidgetSkill()),
+ getComponentCatalog: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "List a compact index of supported Custom JSX component names, categories, shared prop names, safety policies, and example IDs. Fetch only the named details needed for the widget.",
+ },
+ })
+ .query(() => getCustomWidgetComponentCatalog()),
+
+ getComponent: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get the installed Custom JSX documentation, allowed props, blocked props, and accessibility requirements for one named component.",
+ },
+ })
+ .input(z.object({ name: z.string().trim().min(1).max(128).describe("A component name from the catalog.") }))
+ .query(({ input }) => {
+ const component = getCustomWidgetComponent(input.name);
+ if (!component) throw new TRPCError({ code: "NOT_FOUND", message: "Custom JSX component not found" });
+ return component;
+ }),
+
+ getSharedProps: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get documentation for only the named shared Custom JSX props. Collect the shared prop names needed by all selected components and fetch them together once.",
+ },
+ })
+ .input(
+ z.object({
+ names: z
+ .array(z.string().trim().min(1).max(128))
+ .min(1)
+ .max(64)
+ .describe("Shared prop names from customWidget_getComponentCatalog.sharedProps.names."),
+ }),
+ )
+ .query(({ input }) => getCustomWidgetSharedProps(input.names)),
+
+ getExample: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get one installed Custom JSX example by its catalog ID. Fetch only an example relevant to the requested interaction pattern.",
+ },
+ })
+ .input(z.object({ name: z.string().trim().min(1).max(128).describe("An example ID from the catalog.") }))
+ .query(({ input }) => {
+ const example = getCustomWidgetExample(input.name);
+ if (!example) throw new TRPCError({ code: "NOT_FOUND", message: "Custom JSX example not found" });
+ return example;
+ }),
+
validate: permissionRequiredProcedure
.requiresPermission("admin")
- .meta({ mcp: { enabled: true, description: "Validate one Custom JSX widget without saving it." } })
- .input(z.object({ widget: z.unknown() }))
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Validate one complete Custom JSX widget without saving it. Multiline JSX may be supplied as templateLines; the result returns precise issue paths to repair before previewing.",
+ },
+ })
+ .input(
+ z.object({
+ widget: z
+ .unknown()
+ .describe("A complete Homarr Custom Widget v2 definition using either template or templateLines."),
+ }),
+ )
.query(({ input }) => {
- const result = customWidgetImportSchema.safeParse(input.widget);
- if (!result.success) {
+ const authoringResult = customWidgetAuthoringDefinitionSchema.safeParse(input.widget);
+ if (!authoringResult.success) {
return {
valid: false as const,
- issues: result.error.issues.map((issue) => ({
+ issues: authoringResult.error.issues.map((issue) => ({
path: issue.path.map(String).join("."),
code: issue.code,
message: issue.message,
})),
};
}
- return { valid: true as const, issues: [], widget: result.data };
+ try {
+ const widget = normalizeCustomWidgetAuthoringDefinition(authoringResult.data);
+ return {
+ valid: true as const,
+ issues: [],
+ summary: {
+ name: widget.name,
+ sourceIds: Object.keys(widget.sources),
+ requestIds: Object.keys(widget.requests),
+ optionIds: Object.keys(widget.options),
+ templateLineCount: widget.template.split("\n").length,
+ },
+ nextStep:
+ "Reuse the validated authoring definition with customWidget_previewCreate, then run every returned query.",
+ };
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return {
+ valid: false as const,
+ issues: error.issues.map((issue) => ({
+ path: issue.path.map(String).join("."),
+ code: issue.code,
+ message: issue.message,
+ })),
+ };
+ }
+ throw error;
+ }
}),
};
diff --git a/packages/api/src/router/custom-widget/preview-base-procedures.ts b/packages/api/src/router/custom-widget/preview-base-procedures.ts
index 3d34fad46..a1eeff2cc 100644
--- a/packages/api/src/router/custom-widget/preview-base-procedures.ts
+++ b/packages/api/src/router/custom-widget/preview-base-procedures.ts
@@ -3,22 +3,25 @@ import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import {
- customWidgetDefinitionSchema,
+ collectCustomWidgetRequestReferences,
+ customWidgetAuthoringDefinitionSchema,
customWidgetSecretsInputSchema,
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
+ normalizeCustomWidgetAuthoringDefinition,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
+import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const previewCreateInputSchema = z.object({
- definition: customWidgetDefinitionSchema,
+ definition: customWidgetAuthoringDefinitionSchema,
secrets: customWidgetSecretsInputSchema.default([]),
definitionId: z.string().optional(),
options: z.record(z.string(), z.unknown()).optional(),
@@ -26,11 +29,20 @@ const previewCreateInputSchema = z.object({
const previewCreateProcedure = permissionRequiredProcedure
.requiresPermission("admin")
- .meta({ mcp: { enabled: true, description: "Create a short-lived preview session for one unsaved custom widget." } })
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Create a short-lived preview for an unsaved custom widget after validation. Prefer templateLines for multiline JSX. The result lists every query that must be tested with customWidget_previewQuery before saving.",
+ },
+ })
.input(previewCreateInputSchema)
.mutation(async ({ ctx, input }) => {
- const options = input.options ?? getCustomWidgetDefaultOptions(input.definition.options);
- const optionIssues = validateCustomWidgetOptions(input.definition.options, options);
+ const definition = parseCustomWidgetAuthoringInput(() =>
+ normalizeCustomWidgetAuthoringDefinition(input.definition),
+ );
+ const options = input.options ?? getCustomWidgetDefaultOptions(definition.options);
+ const optionIssues = validateCustomWidgetOptions(definition.options, options);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
throw new TRPCError({
@@ -48,7 +60,7 @@ const previewCreateProcedure = permissionRequiredProcedure
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const existingDefinition = parseStoredCustomWidgetDefinition(existing);
for (const [sourceId, existingSource] of Object.entries(existingDefinition.sources)) {
- const submittedSource = input.definition.sources[sourceId];
+ const submittedSource = definition.sources[sourceId];
const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
@@ -65,7 +77,7 @@ const previewCreateProcedure = permissionRequiredProcedure
}
for (const secret of existing.secrets) {
const existingSource = existingDefinition.sources[secret.sourceId];
- const submittedSource = input.definition.sources[secret.sourceId];
+ const submittedSource = definition.sources[secret.sourceId];
if (
existingSource &&
submittedSource &&
@@ -78,7 +90,7 @@ const previewCreateProcedure = permissionRequiredProcedure
}
const invalid = secrets.find((secret) => {
- const source = input.definition.sources[secret.sourceId];
+ const source = definition.sources[secret.sourceId];
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
const kinds =
authType === "basic"
@@ -94,26 +106,32 @@ const previewCreateProcedure = permissionRequiredProcedure
const previewSession = await createPreviewSession({
userId: ctx.session.user.id,
- sources: input.definition.sources,
- requests: input.definition.requests,
- name: input.definition.name,
- template: input.definition.template,
- optionDefinitions: input.definition.options,
+ sources: definition.sources,
+ requests: definition.requests,
+ name: definition.name,
+ template: definition.template,
+ optionDefinitions: definition.options,
options,
secrets,
definitionId: input.definitionId,
});
+ const previewPath = `/manage/custom-widgets/preview/${previewSession.id}`;
return {
success: true as const,
previewSession,
- previewUrl: new URL(
- `/manage/custom-widgets/preview/${previewSession.id}`,
- ctx.baseUrl ?? "http://localhost",
- ).toString(),
- definition: {
- template: input.definition.template,
- defaultOptions: getCustomWidgetDefaultOptions(input.definition.options),
- },
+ previewPath,
+ previewUrl: new URL(previewPath, ctx.baseUrl ?? "http://localhost").toString(),
+ queries: Object.entries(definition.requests).flatMap(([requestId, request]) => {
+ if (request.kind !== "query") return [];
+ return [
+ {
+ requestId,
+ trigger: request.trigger,
+ parameterNames: [...collectCustomWidgetRequestReferences(request).params],
+ nextStep: `Call customWidget_previewQuery with sessionId '${previewSession.id}' and requestId '${requestId}'.`,
+ },
+ ];
+ }),
};
});
diff --git a/packages/api/src/router/custom-widget/preview-procedure-helpers.ts b/packages/api/src/router/custom-widget/preview-procedure-helpers.ts
index 652f60f39..498b58374 100644
--- a/packages/api/src/router/custom-widget/preview-procedure-helpers.ts
+++ b/packages/api/src/router/custom-widget/preview-procedure-helpers.ts
@@ -10,9 +10,16 @@ import { resolveCustomWidgetRequestValues } from "./request-manifest";
const logger = createLogger({ module: "custom-widget-preview" });
export const previewSessionRequestSchema = z.object({
- sessionId: z.string().min(1),
- requestId: z.string().min(1).max(64),
- params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
+ sessionId: z.string().min(1).describe("The previewSession.id returned by customWidget_previewCreate."),
+ requestId: z
+ .string()
+ .min(1)
+ .max(64)
+ .describe("One query requestId from customWidget_previewCreate.queries. Test every returned query."),
+ params: z
+ .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
+ .default({})
+ .describe("Invocation parameters required by a manual query. Load queries use an empty object."),
});
export const recordPreviewJournal = async (...args: Parameters) => {
diff --git a/packages/api/src/router/custom-widget/preview-query-procedures.ts b/packages/api/src/router/custom-widget/preview-query-procedures.ts
index 18e527c72..523962327 100644
--- a/packages/api/src/router/custom-widget/preview-query-procedures.ts
+++ b/packages/api/src/router/custom-widget/preview-query-procedures.ts
@@ -19,7 +19,8 @@ export const previewQueryProcedures = {
.meta({
mcp: {
enabled: true,
- description: "Run one named query from a short-lived custom widget preview session.",
+ description:
+ "Execute one real API query from customWidget_previewCreate.queries and return its HTTP status plus parsed data so request paths and template bindings can be verified. Call once for every returned query before customWidget_create.",
},
})
.input(previewSessionRequestSchema)
diff --git a/packages/api/src/router/icons.ts b/packages/api/src/router/icons.ts
index 6758dd012..214ee894b 100644
--- a/packages/api/src/router/icons.ts
+++ b/packages/api/src/router/icons.ts
@@ -10,7 +10,7 @@ export const iconsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
- "Search for icons by name across all icon repositories. OPTIONAL: searchText (string to filter), limitPerGroup (number 1-500, default 12). Call with no arguments to browse all icons",
+ "Search for icons by name across all icon repositories. Returns matching icon names and exact image URLs; use those URLs directly for app icons and Markdown image previews. OPTIONAL: searchText (string to filter), limitPerGroup (number 1-500, default 12). Call with no arguments to browse all icons",
},
})
.input(iconsFindSchema)
@@ -23,8 +23,8 @@ export const iconsRouter = createTRPCRouter({
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
}
- return {
- icons: await ctx.db.query.iconRepositories.findMany({
+ const [repositories, countIcons] = await Promise.all([
+ ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: { id: true, name: true, url: true },
@@ -47,7 +47,12 @@ export const iconsRouter = createTRPCRouter({
},
},
}),
- countIcons: await ctx.db.$count(icons),
+ ctx.db.$count(icons),
+ ]);
+
+ return {
+ icons: term.length > 0 ? repositories.filter((repository) => repository.icons.length > 0) : repositories,
+ countIcons,
};
}),
});
diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts
index 4f43a9533..c195c9815 100644
--- a/packages/api/src/router/onboard/onboard-router.ts
+++ b/packages/api/src/router/onboard/onboard-router.ts
@@ -294,9 +294,7 @@ export const onboardRouter = createTRPCRouter({
}
const section = board.sections.find((boardSection) => boardSection.kind === "empty");
- const layout = board.layouts[0];
-
- if (section && layout) {
+ if (section && board.layouts.length > 0) {
const existingItems = await db.query.items.findMany({
where: eq(items.boardId, board.id),
});
@@ -310,12 +308,12 @@ export const onboardRouter = createTRPCRouter({
await placeAllWidgetsAsync(
db,
- {
+ board.layouts.map((layout) => ({
boardId: board.id,
sectionId: section.id,
layoutId: layout.id,
columnCount: layout.columnCount,
- },
+ })),
allIntegrations,
allApps,
);
diff --git a/packages/api/src/router/test/assistant.spec.ts b/packages/api/src/router/test/assistant.spec.ts
new file mode 100644
index 000000000..885e78690
--- /dev/null
+++ b/packages/api/src/router/test/assistant.spec.ts
@@ -0,0 +1,360 @@
+// @vitest-environment node
+
+import { describe, expect, test, vi } from "vitest";
+import { parse } from "superjson";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import { eq } from "@homarr/db";
+import {
+ apps,
+ assistantConfigurations,
+ assistantMessages,
+ assistantThreads,
+ boards,
+ integrations,
+ integrationUserPermissions,
+ items,
+ users,
+} from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { assistantRouter } from "../assistant";
+
+vi.mock("@homarr/auth", () => ({}));
+vi.mock("@homarr/common/server", () => ({
+ decryptSecret: vi.fn(),
+ encryptSecret: vi.fn(),
+}));
+vi.mock("@homarr/core/infrastructure/logs", () => ({
+ createLogger: () => ({
+ error: vi.fn(),
+ }),
+}));
+vi.mock("@homarr/core/infrastructure/db/env", () => ({
+ dbEnv: { DRIVER: "better-sqlite3" },
+}));
+
+const adminSession = {
+ user: {
+ id: createId(),
+ permissions: ["admin"],
+ colorScheme: "light",
+ },
+ expires: new Date().toISOString(),
+} satisfies Session;
+
+const createConfiguredAssistantAsync = async () => {
+ const db = createDb();
+ await db.insert(assistantConfigurations).values({
+ id: "default",
+ enabled: true,
+ provider: "openrouter",
+ baseUrl: "https://openrouter.ai/api/v1",
+ modelDiscoveryPath: "/models",
+ encryptedApiKey: "encrypted.key",
+ encryptedHeaders: "encrypted.headers",
+ modelId: "example/model",
+ });
+ return db;
+};
+
+describe("assistantRouter.updateConnection", () => {
+ test("clears the persisted model when the discovery path changes without clearing credentials", async () => {
+ const db = await createConfiguredAssistantAsync();
+ const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await caller.updateConnection({
+ provider: "openrouter",
+ baseUrl: "https://openrouter.ai/api/v1",
+ modelDiscoveryPath: "/catalog",
+ clearApiKey: false,
+ clearCustomHeaders: false,
+ });
+
+ const [configuration] = await db.select().from(assistantConfigurations);
+ expect(configuration).toMatchObject({
+ enabled: false,
+ modelDiscoveryPath: "/catalog",
+ modelId: null,
+ encryptedApiKey: "encrypted.key",
+ encryptedHeaders: "encrypted.headers",
+ });
+ });
+
+ test("disables OpenRouter server tools when the provider destination changes", async () => {
+ const db = await createConfiguredAssistantAsync();
+ await db
+ .update(assistantConfigurations)
+ .set({ webSearchEnabled: true })
+ .where(eq(assistantConfigurations.id, "default"));
+ const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await caller.updateConnection({
+ provider: "openai",
+ baseUrl: "https://api.openai.com/v1",
+ modelDiscoveryPath: "/models",
+ clearApiKey: false,
+ clearCustomHeaders: false,
+ });
+
+ const [configuration] = await db.select().from(assistantConfigurations);
+ expect(configuration?.webSearchEnabled).toBe(false);
+ });
+
+ test("preserves the persisted model when the saved connection is unchanged", async () => {
+ const db = await createConfiguredAssistantAsync();
+ const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await caller.updateConnection({
+ provider: "openrouter",
+ baseUrl: "https://openrouter.ai/api/v1",
+ modelDiscoveryPath: "/models",
+ clearApiKey: false,
+ clearCustomHeaders: false,
+ });
+
+ const [configuration] = await db.select().from(assistantConfigurations);
+ expect(configuration).toMatchObject({
+ enabled: true,
+ modelDiscoveryPath: "/models",
+ modelId: "example/model",
+ });
+ });
+});
+
+describe("assistantRouter.updateConfiguration", () => {
+ test("normalizes legacy model labels to the discovered provider ID", async () => {
+ const db = await createConfiguredAssistantAsync();
+ await db
+ .update(assistantConfigurations)
+ .set({ encryptedHeaders: null })
+ .where(eq(assistantConfigurations.id, "default"));
+ const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ Response.json({
+ data: [
+ {
+ id: "deepseek/deepseek-v4-pro",
+ name: "DeepSeek: DeepSeek V4 Pro",
+ supported_parameters: ["tools"],
+ architecture: { output_modalities: ["text"] },
+ },
+ ],
+ }),
+ ),
+ );
+
+ try {
+ await caller.updateConfiguration({
+ enabled: true,
+ modelId: "DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro)",
+ });
+ } finally {
+ vi.unstubAllGlobals();
+ }
+
+ const [configuration] = await db.select().from(assistantConfigurations);
+ expect(configuration?.modelId).toBe("deepseek/deepseek-v4-pro");
+ });
+
+ test("persists the OpenRouter web-search server tool option", async () => {
+ const db = await createConfiguredAssistantAsync();
+ const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await caller.updateConfiguration({ enabled: true, modelId: "example/model", webSearchEnabled: true });
+
+ const [configuration] = await db.select().from(assistantConfigurations);
+ expect(configuration?.webSearchEnabled).toBe(true);
+ });
+
+ test("rejects OpenRouter server tools for an incompatible provider", async () => {
+ const db = await createConfiguredAssistantAsync();
+ await db
+ .update(assistantConfigurations)
+ .set({ provider: "openai", baseUrl: "https://api.openai.com/v1" })
+ .where(eq(assistantConfigurations.id, "default"));
+ const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ await expect(
+ caller.updateConfiguration({ enabled: true, modelId: "gpt-5", webSearchEnabled: true }),
+ ).rejects.toThrow("Web search requires OpenRouter or an OpenRouter-compatible proxy endpoint.");
+ });
+});
+
+describe("assistant conversation features", () => {
+ test("returns safe runtime model options to signed-in users", async () => {
+ const db = await createConfiguredAssistantAsync();
+ await db
+ .update(assistantConfigurations)
+ .set({
+ baseUrl: "https://models.example/v1",
+ encryptedHeaders: null,
+ updatedAt: new Date("2026-07-30T14:00:00.000Z"),
+ })
+ .where(eq(assistantConfigurations.id, "default"));
+ const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ Response.json({
+ data: [
+ {
+ id: "example/model",
+ name: "Example Model",
+ supported_parameters: ["tools"],
+ architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] },
+ },
+ {
+ id: "example/no-tools",
+ name: "No tools",
+ supported_parameters: [],
+ architecture: { output_modalities: ["text"] },
+ },
+ {
+ id: "example/unnamed",
+ name: "",
+ supported_parameters: ["tools"],
+ architecture: { output_modalities: ["text"] },
+ },
+ ],
+ }),
+ ),
+ );
+
+ try {
+ const options = await caller.getRuntimeOptions();
+
+ expect(options).toEqual({
+ provider: "openrouter",
+ defaultModelId: "example/model",
+ models: [
+ expect.objectContaining({
+ id: "example/model",
+ name: "Example Model",
+ inputModalities: ["text", "image"],
+ }),
+ expect.objectContaining({
+ id: "example/unnamed",
+ name: "example/unnamed",
+ }),
+ ],
+ });
+ expect(JSON.stringify(options)).not.toContain("encrypted");
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ test("returns permission-checked entities for composer mentions", async () => {
+ const db = await createConfiguredAssistantAsync();
+ await db.insert(users).values({ id: adminSession.user.id });
+ await db.insert(apps).values({ id: "app-1", name: "Plex", iconUrl: "https://example.com/plex.svg" });
+ await db.insert(boards).values({ id: "board-1", name: "Home", isPublic: true });
+ await db.insert(items).values({ id: "widget-1", boardId: "board-1", kind: "clock" });
+ await db.insert(integrations).values({
+ id: "integration-1",
+ name: "Media server",
+ kind: "plex",
+ url: "https://example.com",
+ });
+ await db.insert(integrationUserPermissions).values({
+ integrationId: "integration-1",
+ userId: adminSession.user.id,
+ permission: "use",
+ });
+ const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+
+ const entities = await caller.getContextEntities();
+
+ expect(entities).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: "app-1",
+ type: "app",
+ label: "Plex",
+ iconUrl: "https://example.com/plex.svg",
+ }),
+ expect.objectContaining({ id: "board-1", type: "board", label: "Home" }),
+ expect.objectContaining({ id: "widget-1", type: "widget", boardId: "board-1" }),
+ expect.objectContaining({
+ id: "integration-1",
+ type: "integration",
+ label: "Media server",
+ iconUrl: expect.stringContaining("plex"),
+ }),
+ ]),
+ );
+ });
+
+ test("persists a discovered model choice on the owned conversation", async () => {
+ const db = await createConfiguredAssistantAsync();
+ await db.insert(users).values({ id: adminSession.user.id });
+ await db
+ .update(assistantConfigurations)
+ .set({
+ baseUrl: "https://thread-models.example/v1",
+ encryptedHeaders: null,
+ updatedAt: new Date("2026-07-30T15:00:00.000Z"),
+ })
+ .where(eq(assistantConfigurations.id, "default"));
+ const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+ const thread = await caller.createThread();
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ Response.json({
+ data: [
+ {
+ id: "example/model",
+ name: "Default",
+ supported_parameters: ["tools"],
+ architecture: { output_modalities: ["text"] },
+ },
+ {
+ id: "example/reasoning",
+ name: "Reasoning",
+ supported_parameters: ["tools"],
+ architecture: { output_modalities: ["text"] },
+ },
+ ],
+ }),
+ ),
+ );
+
+ try {
+ await caller.updateThreadModel({ threadId: thread.id, modelId: "example/reasoning" });
+ } finally {
+ vi.unstubAllGlobals();
+ }
+
+ const storedThread = await db.query.assistantThreads.findFirst({
+ where: eq(assistantThreads.id, thread.id),
+ });
+ expect(storedThread?.modelId).toBe("example/reasoning");
+ });
+
+ test("persists feedback on an owned assistant message", async () => {
+ const db = await createConfiguredAssistantAsync();
+ await db.insert(users).values({ id: adminSession.user.id });
+ const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
+ const thread = await caller.createThread();
+ await caller.appendMessage({
+ threadId: thread.id,
+ id: "message-1",
+ parentId: null,
+ format: "ai-sdk/v6",
+ content: { role: "assistant", parts: [{ type: "text", text: "Hello" }], metadata: { custom: {} } },
+ });
+
+ await caller.submitFeedback({ threadId: thread.id, messageId: "message-1", type: "positive" });
+
+ const [message] = await db.select().from(assistantMessages);
+ expect(message).toBeDefined();
+ expect(parse(message?.content ?? "")).toMatchObject({
+ metadata: { submittedFeedback: { type: "positive" } },
+ });
+ });
+});
diff --git a/packages/api/src/router/test/board-add-item.spec.ts b/packages/api/src/router/test/board-add-item.spec.ts
new file mode 100644
index 000000000..4184c2b5d
--- /dev/null
+++ b/packages/api/src/router/test/board-add-item.spec.ts
@@ -0,0 +1,153 @@
+// @vitest-environment node
+
+import { parse } from "superjson";
+import { describe, expect, test } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import { boards, integrations, integrationUserPermissions, items, layouts, sections, users } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { boardRouter } from "../board";
+
+const createBoardCallerAsync = async () => {
+ const db = createDb();
+ const userId = createId();
+ const boardId = createId();
+ await db.insert(users).values({ id: userId });
+ await db.insert(boards).values({ id: boardId, name: "Home", creatorId: userId });
+ await db.insert(layouts).values({
+ id: createId(),
+ name: "Base",
+ boardId,
+ columnCount: 12,
+ breakpoint: 0,
+ });
+ await db.insert(sections).values({ id: createId(), boardId, kind: "empty", xOffset: 0, yOffset: 0 });
+ const session = {
+ user: { id: userId, permissions: [], colorScheme: "light" },
+ expires: new Date().toISOString(),
+ } satisfies Session;
+ return { db, userId, boardId, caller: boardRouter.createCaller({ db, deviceType: undefined, session }) };
+};
+
+describe("board.addItem", () => {
+ test("preserves configured notebook content when placing the widget", async () => {
+ const { db, boardId, caller } = await createBoardCallerAsync();
+ const content = "Self-host Plex Install Docker and configure your media volumes.
";
+
+ const result = await caller.addItem({
+ boardId,
+ kind: "notebook",
+ options: { content, showToolbar: true, allowReadOnlyCheck: true },
+ integrationIds: [],
+ });
+
+ const item = await db.query.items.findFirst({ where: (table, { eq }) => eq(table.id, result.itemId) });
+ expect(item?.kind).toBe("notebook");
+ expect(parse(item?.options ?? "{}")).toEqual({
+ content,
+ showToolbar: true,
+ allowReadOnlyCheck: true,
+ });
+ });
+
+ test("accepts an integration the current user can use", async () => {
+ const { db, userId, boardId, caller } = await createBoardCallerAsync();
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "Plex",
+ kind: "plex",
+ url: "https://plex.example.com",
+ });
+ await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
+
+ await expect(
+ caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
+ ).resolves.toMatchObject({ itemId: expect.any(String) });
+ });
+
+ test("rejects an integration the current user cannot use", async () => {
+ const { db, boardId, caller } = await createBoardCallerAsync();
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "Private Plex",
+ kind: "plex",
+ url: "https://private.example.com",
+ });
+
+ await expect(
+ caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
+ ).rejects.toThrow("Integration not found");
+
+ await expect(db.$count(items)).resolves.toBe(0);
+ });
+
+ test("rejects a missing required integration", async () => {
+ const { db, boardId, caller } = await createBoardCallerAsync();
+
+ await expect(caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [] })).rejects.toThrow(
+ "mediaServer requires an integration",
+ );
+
+ await expect(db.$count(items)).resolves.toBe(0);
+ });
+
+ test("allows an explicitly optional integration to be omitted", async () => {
+ const { boardId, caller } = await createBoardCallerAsync();
+
+ await expect(caller.addItem({ boardId, kind: "calendar", options: {}, integrationIds: [] })).resolves.toMatchObject(
+ { itemId: expect.any(String) },
+ );
+ });
+
+ test("rejects an incompatible integration kind", async () => {
+ const { db, userId, boardId, caller } = await createBoardCallerAsync();
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "Sonarr",
+ kind: "sonarr",
+ url: "https://sonarr.example.com",
+ });
+ await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
+
+ await expect(
+ caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
+ ).rejects.toThrow("mediaServer does not support integration kind: sonarr");
+
+ await expect(db.$count(items)).resolves.toBe(0);
+ });
+
+ test("rejects more integrations than the widget supports", async () => {
+ const { db, userId, boardId, caller } = await createBoardCallerAsync();
+ const navidromeId = createId();
+ const audiobookshelfId = createId();
+ await db.insert(integrations).values([
+ { id: navidromeId, name: "Navidrome", kind: "navidrome", url: "https://music.example.com" },
+ {
+ id: audiobookshelfId,
+ name: "Audiobookshelf",
+ kind: "audiobookshelf",
+ url: "https://books.example.com",
+ },
+ ]);
+ await db.insert(integrationUserPermissions).values([
+ { integrationId: navidromeId, userId, permission: "use" },
+ { integrationId: audiobookshelfId, userId, permission: "use" },
+ ]);
+
+ await expect(
+ caller.addItem({
+ boardId,
+ kind: "audioStats",
+ options: {},
+ integrationIds: [navidromeId, audiobookshelfId],
+ }),
+ ).rejects.toThrow("audioStats supports at most 1 integration");
+
+ await expect(db.$count(items)).resolves.toBe(0);
+ });
+});
diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts
index 5fdfd191e..dbbb7f25c 100644
--- a/packages/api/src/router/test/board.spec.ts
+++ b/packages/api/src/router/test/board.spec.ts
@@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
-import { and, eq, not } from "@homarr/db";
+import { and, eq } from "@homarr/db";
import {
boardGroupPermissions,
boards,
@@ -325,10 +325,13 @@ describe("createBoard should create a new board", () => {
expect(firstSection?.xOffset).toBe(0);
expect(firstSection?.yOffset).toBe(0);
- expect(dbBoard?.layouts.length).toBe(1);
- const firstLayout = dbBoard?.layouts.at(0);
- expect(firstLayout?.columnCount).toBe(24);
- expect(firstLayout?.breakpoint).toBe(0);
+ expect(dbBoard?.layouts.length).toBe(2);
+ expect(dbBoard?.layouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }),
+ expect.objectContaining({ name: "Base", role: "base", columnCount: 24, breakpoint: 768 }),
+ ]),
+ );
});
test("should throw error when user has no board-create permission", async () => {
@@ -344,6 +347,36 @@ describe("createBoard should create a new board", () => {
});
});
+describe("duplicateBoard should preserve protected layout invariants", () => {
+ test("normalizes a legacy single-layout board while preserving Base positions", async () => {
+ const db = createDb();
+ const session = {
+ ...defaultSession,
+ user: {
+ ...defaultSession.user,
+ permissions: ["board-create"] satisfies GroupPermissionKey[],
+ },
+ };
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
+ const { boardId } = await createFullBoardAsync(db, "source");
+
+ const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
+
+ const copiedBoard = await db.query.boards.findFirst({
+ where: eq(boards.id, result.boardId),
+ with: { layouts: true, items: { with: { layouts: true } } },
+ });
+ expect(copiedBoard?.layouts).toHaveLength(2);
+ expect(copiedBoard?.layouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
+ expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
+ ]),
+ );
+ expect(copiedBoard?.items.at(0)?.layouts).toHaveLength(2);
+ });
+});
+
describe("rename board should rename board", () => {
test("should rename board", async () => {
// Arrange
@@ -569,6 +602,13 @@ describe("getBoardByName should return board by name", () => {
name,
...fullBoardProps,
});
+ expect(result.layouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ role: "mobile", breakpoint: 0, columnCount: 3 }),
+ expect.objectContaining({ id: fullBoardProps.layoutId, role: "base", breakpoint: 768 }),
+ ]),
+ );
+ expect(result.items.at(0)?.layouts).toHaveLength(2);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
@@ -618,6 +658,28 @@ describe("getBoardByName should return board by name", () => {
});
});
+describe("getBoardSettings should return editable board settings", () => {
+ test("returns current custom CSS and requires modify permission", async () => {
+ const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId } = await createFullBoardAsync(db, "default");
+ const customCss = ".dashboard-card { border-radius: 0.5rem; }";
+ await db.update(boards).set({ customCss, primaryColor: "#123456", pageTitle: null }).where(eq(boards.id, boardId));
+
+ const result = await caller.getBoardSettings({ id: boardId });
+
+ expect(result).toMatchObject({
+ id: boardId,
+ name: "default",
+ customCss,
+ primaryColor: "#123456",
+ pageTitle: null,
+ });
+ expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
+ });
+});
+
describe("savePartialBoardSettings should save general settings", () => {
it("should save general settings", async () => {
// Arrange
@@ -1330,12 +1392,12 @@ describe("saveBoard should save full board", () => {
expect(firstItem.id).toBe(itemId);
expect(firstItem.kind).toBe("clock");
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
- const firstLayout = expectToBeDefined(firstItem.layouts[0]);
- expect(firstLayout.sectionId).toBe(sectionId);
- expect(firstLayout.height).toBe(3);
- expect(firstLayout.width).toBe(2);
- expect(firstLayout.xOffset).toBe(7);
- expect(firstLayout.yOffset).toBe(5);
+ const updatedLayout = expectToBeDefined(firstItem.layouts.find((layout) => layout.layoutId === layoutId));
+ expect(updatedLayout.sectionId).toBe(sectionId);
+ expect(updatedLayout.height).toBe(3);
+ expect(updatedLayout.width).toBe(2);
+ expect(updatedLayout.xOffset).toBe(7);
+ expect(updatedLayout.yOffset).toBe(5);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should fail when board not found", async () => {
@@ -1522,7 +1584,17 @@ const createExistingLayout = (id: string) => ({
columnCount: 10,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
+ breakpoint: 768,
+ role: "base" as const,
+});
+const createMobileLayout = (id: string) => ({
+ id,
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
breakpoint: 0,
+ role: "mobile" as const,
});
const createNewLayout = (columnCount: number) => ({
id: createId(),
@@ -1530,8 +1602,23 @@ const createNewLayout = (columnCount: number) => ({
columnCount,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
- breakpoint: 1400,
+ breakpoint: 480,
+ role: "custom" as const,
});
+const addMobileLayoutAsync = async (db: Database, boardId: string) => {
+ const mobileLayoutId = createId();
+ await db.insert(layouts).values({
+ id: mobileLayoutId,
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 0,
+ role: "mobile",
+ boardId,
+ });
+ return mobileLayoutId;
+};
describe("saveLayouts should save layout changes", () => {
test("should add layout when not present in database", async () => {
// Arrange
@@ -1539,23 +1626,26 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const newLayout = createNewLayout(12);
// Act
- await caller.saveLayouts({
+ const savedLayouts = await caller.saveLayouts({
id: boardId,
- layouts: [createExistingLayout(layoutId), newLayout],
+ layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
- where: not(eq(layouts.id, layoutId)),
+ where: eq(layouts.role, "custom"),
});
const definedLayout = expectToBeDefined(layout);
expect(definedLayout.name).toBe(newLayout.name);
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
+ expect(definedLayout.role).toBe("custom");
+ expect(savedLayouts.find((candidate) => candidate.role === "custom")?.id).toBe(definedLayout.id);
});
test("should add items and containers generated from grid-algorithm when new layout is added", async () => {
// Arrange
@@ -1563,6 +1653,7 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
@@ -1573,12 +1664,12 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
- layouts: [createExistingLayout(layoutId), newLayout],
+ layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
- where: not(eq(layouts.id, layoutId)),
+ where: eq(layouts.role, "custom"),
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForRootLayoutAsync(db, sectionId, layout!.id, {
@@ -1594,6 +1685,7 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const updatedLayout = createExistingLayout(layoutId);
updatedLayout.breakpoint = 1400;
updatedLayout.name = "Updated layout";
@@ -1601,7 +1693,7 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
- layouts: [updatedLayout],
+ layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
});
// Assert
@@ -1620,6 +1712,7 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
@@ -1631,7 +1724,7 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
- layouts: [updatedLayout],
+ layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
});
// Assert
@@ -1645,6 +1738,7 @@ describe("saveLayouts should save layout changes", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "gutters");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
await db
.update(itemLayouts)
@@ -1656,6 +1750,7 @@ describe("saveLayouts should save layout changes", () => {
await caller.saveLayouts({
id: boardId,
layouts: [
+ createMobileLayout(mobileLayoutId),
{
...createExistingLayout(layoutId),
leftGutterColumnCount: 2,
@@ -1684,7 +1779,7 @@ describe("saveLayouts should save layout changes", () => {
});
await caller.saveLayouts({
id: boardId,
- layouts: [createExistingLayout(layoutId)],
+ layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
});
const returnedItem = expectToBeDefined(
@@ -1698,9 +1793,11 @@ describe("saveLayouts should save layout changes", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "concurrent-gutters");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const input = {
id: boardId,
layouts: [
+ createMobileLayout(mobileLayoutId),
{
...createExistingLayout(layoutId),
leftGutterColumnCount: 2,
@@ -1715,22 +1812,73 @@ describe("saveLayouts should save layout changes", () => {
});
expect(leftRoots).toHaveLength(1);
});
+ test("should project a new custom layout from the resized Base layout", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
+ const secondItemId = createId();
+
+ await db.update(itemLayouts).set({ width: 4, xOffset: 0 }).where(eq(itemLayouts.itemId, itemId));
+ await db.insert(items).values({
+ id: secondItemId,
+ kind: "clock",
+ boardId,
+ options: SuperJSON.stringify({ is24HourFormat: true }),
+ });
+ await db.insert(itemLayouts).values({
+ itemId: secondItemId,
+ layoutId,
+ sectionId,
+ width: 4,
+ height: 1,
+ xOffset: 6,
+ yOffset: 0,
+ });
+
+ const resizedBase = { ...createExistingLayout(layoutId), columnCount: 6 };
+ const savedLayouts = await caller.saveLayouts({
+ id: boardId,
+ layouts: [createMobileLayout(mobileLayoutId), createNewLayout(8), resizedBase],
+ });
+ const customLayoutId = expectToBeDefined(savedLayouts.find((layout) => layout.role === "custom")).id;
+
+ const basePosition = await db.query.itemLayouts.findFirst({
+ where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, layoutId)),
+ });
+ const customPosition = await db.query.itemLayouts.findFirst({
+ where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, customLayoutId)),
+ });
+
+ expect(basePosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
+ expect(customPosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
+ });
test("should remove layout when not present in input", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
+ const customLayoutId = createId();
+ await db.insert(layouts).values({
+ id: customLayoutId,
+ name: "Custom",
+ columnCount: 6,
+ breakpoint: 480,
+ role: "custom",
+ boardId,
+ });
// Act
await caller.saveLayouts({
id: boardId,
- layouts: [createNewLayout(12)],
+ layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
});
// Assert
const layout = await db.query.layouts.findFirst({
- where: eq(layouts.id, layoutId),
+ where: eq(layouts.id, customLayoutId),
});
expect(layout).toBeUndefined();
});
@@ -1745,12 +1893,73 @@ describe("saveLayouts should save layout changes", () => {
const actAsync = async () =>
await caller.saveLayouts({
id: createId(),
- layouts: [createExistingLayout(layoutId)],
+ layouts: [createMobileLayout(createId()), createExistingLayout(layoutId)],
});
// Assert
await expect(actAsync()).rejects.toThrowError("Board not found");
});
+ test("should reject changing protected layout roles", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
+
+ const actAsync = async () =>
+ await caller.saveLayouts({
+ id: boardId,
+ layouts: [
+ { ...createExistingLayout(layoutId), role: "mobile", breakpoint: 0 },
+ { ...createMobileLayout(mobileLayoutId), role: "base", breakpoint: 768 },
+ ],
+ });
+
+ await expect(actAsync()).rejects.toThrowError("Layout roles cannot be changed");
+ });
+});
+
+describe("resetLayout should rebuild a layout from Base", () => {
+ test("should replace target item positions and preserve layout metadata", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ await addMobileLayoutAsync(db, boardId);
+ const customLayoutId = createId();
+ await db.insert(layouts).values({
+ id: customLayoutId,
+ name: "Tablet",
+ columnCount: 6,
+ breakpoint: 480,
+ role: "custom",
+ boardId,
+ });
+ await db.insert(itemLayouts).values({
+ itemId,
+ sectionId,
+ layoutId: customLayoutId,
+ width: 2,
+ height: 2,
+ xOffset: 3,
+ yOffset: 4,
+ });
+
+ await caller.resetLayout({ boardId, layoutId: customLayoutId });
+
+ const resetPosition = await db.query.itemLayouts.findFirst({
+ where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, customLayoutId)),
+ });
+ expect(resetPosition).toMatchObject({ width: 1, height: 1, xOffset: 0, yOffset: 0, sectionId });
+ const customLayout = await db.query.layouts.findFirst({ where: eq(layouts.id, customLayoutId) });
+ expect(customLayout).toMatchObject({ name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" });
+ });
+
+ test("should reject resetting Base", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+
+ await expect(caller.resetLayout({ boardId, layoutId })).rejects.toThrowError("Base layout cannot be reset");
+ });
});
describe("Custom Widget placement permissions", () => {
@@ -1848,7 +2057,8 @@ const createFullBoardAsync = async (db: Database, name: string) => {
id: layoutId,
name: "Base",
columnCount: 10,
- breakpoint: 0,
+ breakpoint: 768,
+ role: "base",
boardId,
});
diff --git a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts
index 3913e8901..35f23b532 100644
--- a/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts
+++ b/packages/api/src/router/test/custom-widget/definition-persistence.spec.ts
@@ -100,6 +100,86 @@ async function insertLegacyJellyfin(db: ReturnType) {
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
describe("custom widget definition persistence", () => {
+ test("creates and previews MCP-friendly multiline templateLines without losing the JSX", async () => {
+ const db = await prepareDatabase();
+ const caller = createCaller(db);
+ const { template, ...definition } = jellyfin;
+ const templateLines = template.split("\n");
+
+ const validation = await caller.validate({ widget: { ...definition, templateLines } });
+ expect(validation).toMatchObject({
+ valid: true,
+ summary: {
+ name: jellyfin.name,
+ sourceIds: Object.keys(jellyfin.sources),
+ requestIds: Object.keys(jellyfin.requests),
+ templateLineCount: templateLines.length,
+ },
+ });
+ expect(validation).not.toHaveProperty("widget");
+ expect(validation).not.toHaveProperty("templateLines");
+ expect(Buffer.byteLength(JSON.stringify(validation), "utf8")).toBeLessThan(2_000);
+ const preview = await caller.previewCreate({ definition: { ...definition, templateLines }, secrets: [] });
+ expect(preview).toMatchObject({
+ success: true,
+ previewPath: `/manage/custom-widgets/preview/${preview.previewSession.id}`,
+ });
+ expect(preview).not.toHaveProperty("definition");
+ expect(Buffer.byteLength(JSON.stringify(preview), "utf8")).toBeLessThan(4_000);
+ expect(preview.queries.map(({ requestId }) => requestId)).toEqual(Object.keys(jellyfin.requests));
+ expect(preview.queries.every(({ nextStep }) => nextStep.includes("customWidget_previewQuery"))).toBe(true);
+
+ const created = await caller.create({ ...definition, templateLines, secrets: [] });
+ expect(created.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
+ expect(created.nextAction).toEqual({
+ type: "place-custom-widget",
+ widgetKind: "customApi",
+ options: { definitionId: created.id },
+ whenTargetIsKnown: "Call configure_widget now with the requested board and these exact widget options.",
+ whenTargetIsUnknown:
+ "Call ask_user now with 'Place on a board' and 'Leave unplaced'. Never ask this choice in prose.",
+ });
+ expect((await caller.get({ id: created.id })).template).toBe(template);
+ });
+
+ test("updates a saved widget with MCP-friendly multiline templateLines", async () => {
+ const db = await prepareDatabase();
+ const caller = createCaller(db);
+ const created = await caller.create({ ...jellyfin, secrets: [] });
+ const updatedTemplate = jellyfin.template.replace("Jellyfin library", "Updated Jellyfin library");
+
+ const updated = await caller.update({ id: created.id, templateLines: updatedTemplate.split("\n") });
+
+ expect(updated.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
+ expect((await caller.get({ id: created.id })).template).toBe(updatedTemplate);
+ });
+
+ test("reports joined templateLines validation issues as BAD_REQUEST for create and update", async () => {
+ const db = await prepareDatabase();
+ const caller = createCaller(db);
+ const { template: _template, ...definition } = jellyfin;
+ const invalidTemplateLines = ["", ' ', " "];
+
+ const createError = await caller
+ .create({ ...definition, templateLines: invalidTemplateLines, secrets: [] })
+ .catch((error: unknown) => error);
+ expect(createError).toMatchObject({
+ code: "BAD_REQUEST",
+ cause: { issues: [expect.objectContaining({ path: ["template"] })] },
+ });
+ expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
+
+ const created = await caller.create({ ...jellyfin, secrets: [] });
+ const updateError = await caller
+ .update({ id: created.id, templateLines: invalidTemplateLines })
+ .catch((error: unknown) => error);
+ expect(updateError).toMatchObject({
+ code: "BAD_REQUEST",
+ cause: { issues: [expect.objectContaining({ path: ["template"] })] },
+ });
+ expect((await caller.get({ id: created.id })).template).toBe(jellyfin.template);
+ });
+
test("atomically creates a v2 replacement while preserving the legacy widget and its stable ID", async () => {
const db = await prepareDatabase();
const legacy = await insertLegacyJellyfin(db);
diff --git a/packages/api/src/router/test/custom-widget/preview-authoring.spec.ts b/packages/api/src/router/test/custom-widget/preview-authoring.spec.ts
new file mode 100644
index 000000000..73f8d4308
--- /dev/null
+++ b/packages/api/src/router/test/custom-widget/preview-authoring.spec.ts
@@ -0,0 +1,94 @@
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import { createDb } from "@homarr/db/test";
+import { users } from "@homarr/db/schema";
+import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ executeRequest: vi.fn(async (_input: unknown) => ({
+ ok: true,
+ status: 200,
+ statusText: "OK",
+ data: { fixtures: [{ id: "match-1", home_team: { name: "PSG" }, away_team: { name: "Lyon" } }] },
+ })),
+}));
+
+vi.mock("../../custom-widget/request-executor", () => ({
+ executeCustomWidgetRequest: mocks.executeRequest,
+ invalidateCustomWidgetResponseCache: vi.fn(),
+}));
+
+vi.mock("../../custom-widget/request-limits", () => ({
+ acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
+}));
+
+import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
+
+describe("custom widget agent preview workflow", () => {
+ beforeEach(() => mocks.executeRequest.mockClear());
+
+ test("runs a named preview query and returns the real response shape to the agent", async () => {
+ const db = createDb();
+ const userId = createId();
+ await db.insert(users).values({ id: userId });
+ const session = {
+ user: { id: userId, permissions: ["admin"], colorScheme: "light" },
+ expires: new Date(Date.now() + 60_000).toISOString(),
+ } satisfies Session;
+ const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
+ const { template, ...definition } = CUSTOM_WIDGET_STARTER;
+ const preview = await caller.previewCreate({
+ definition: {
+ ...definition,
+ requests: { fixtures: { path: "/fixtures", query: { season: 2026 } } },
+ templateLines: template.split("\n"),
+ },
+ secrets: [],
+ });
+
+ expect(preview.queries).toEqual([
+ expect.objectContaining({ requestId: "fixtures", trigger: "load", parameterNames: [] }),
+ ]);
+ await expect(
+ caller.previewQuery({ sessionId: preview.previewSession.id, requestId: "fixtures", params: {} }),
+ ).resolves.toEqual(
+ expect.objectContaining({
+ ok: true,
+ status: 200,
+ data: { fixtures: [expect.objectContaining({ id: "match-1" })] },
+ }),
+ );
+ const request = mocks.executeRequest.mock.calls[0]?.[0] as { targetUrl: string | URL; kind: string } | undefined;
+ expect(String(request?.targetUrl)).toBe("https://example.com/fixtures?season=2026");
+ expect(request?.kind).toBe("query");
+ });
+
+ test("reports joined preview templateLines validation issues as BAD_REQUEST", async () => {
+ const db = createDb();
+ const userId = createId();
+ await db.insert(users).values({ id: userId });
+ const session = {
+ user: { id: userId, permissions: ["admin"], colorScheme: "light" },
+ expires: new Date(Date.now() + 60_000).toISOString(),
+ } satisfies Session;
+ const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
+ const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
+
+ const error = await caller
+ .previewCreate({
+ definition: {
+ ...definition,
+ templateLines: ["", ' ', " "],
+ },
+ secrets: [],
+ })
+ .catch((cause: unknown) => cause);
+
+ expect(error).toMatchObject({
+ code: "BAD_REQUEST",
+ cause: { issues: [expect.objectContaining({ path: ["template"] })] },
+ });
+ expect(mocks.executeRequest).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/api/src/router/test/icons.spec.ts b/packages/api/src/router/test/icons.spec.ts
new file mode 100644
index 000000000..e38f67b2d
--- /dev/null
+++ b/packages/api/src/router/test/icons.spec.ts
@@ -0,0 +1,54 @@
+// @vitest-environment node
+
+import { describe, expect, test } from "vitest";
+
+import { iconRepositories, icons } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { iconsRouter } from "../icons";
+
+describe("findIcons", () => {
+ test("returns only repositories containing matches for a search", async () => {
+ const db = createDb();
+ await db.insert(iconRepositories).values([
+ { id: "dashboard-icons", slug: "homarr-labs/dashboard-icons" },
+ { id: "other-icons", slug: "example/other-icons" },
+ ]);
+ await db.insert(icons).values([
+ {
+ id: "discord-svg",
+ name: "discord.svg",
+ url: "https://cdn.example.com/discord.svg",
+ checksum: "discord",
+ iconRepositoryId: "dashboard-icons",
+ },
+ {
+ id: "homarr-svg",
+ name: "homarr.svg",
+ url: "https://cdn.example.com/homarr.svg",
+ checksum: "homarr",
+ iconRepositoryId: "other-icons",
+ },
+ ]);
+ const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
+
+ const result = await caller.findIcons({ searchText: "discord", limitPerGroup: 6 });
+
+ expect(result.countIcons).toBe(2);
+ expect(result.icons).toHaveLength(1);
+ expect(result.icons[0]).toMatchObject({
+ slug: "homarr-labs/dashboard-icons",
+ icons: [{ name: "discord.svg", url: "https://cdn.example.com/discord.svg" }],
+ });
+ });
+
+ test("keeps repositories when browsing without a search", async () => {
+ const db = createDb();
+ await db.insert(iconRepositories).values({ id: "empty", slug: "example/empty" });
+ const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
+
+ const result = await caller.findIcons({ limitPerGroup: 6 });
+
+ expect(result.icons).toMatchObject([{ slug: "example/empty", icons: [] }]);
+ });
+});
diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts
index a7cdddfa2..b1bd1d4ff 100644
--- a/packages/api/src/router/test/integration/integration-router.spec.ts
+++ b/packages/api/src/router/test/integration/integration-router.spec.ts
@@ -29,12 +29,12 @@ vi.mock("../../integration/integration-test-connection", () => ({
}));
describe("all should return all integrations", () => {
- test("with any session should return all integrations", async () => {
+ test("with integration full access should return all integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
deviceType: undefined,
- session: defaultSessionWithPermissions(),
+ session: defaultSessionWithPermissions(["integration-full-all"]),
});
await db.insert(integrations).values([
diff --git a/packages/api/src/router/test/widgets/calendar.spec.ts b/packages/api/src/router/test/widgets/calendar.spec.ts
new file mode 100644
index 000000000..d83611451
--- /dev/null
+++ b/packages/api/src/router/test/widgets/calendar.spec.ts
@@ -0,0 +1,109 @@
+// @vitest-environment node
+
+import { describe, expect, test, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { calendarRouter } from "../../widgets/calendar";
+
+const { getDataAsync, handler } = vi.hoisted(() => {
+ const getDataAsync = vi.fn();
+ return { getDataAsync, handler: vi.fn(() => ({ getDataAsync })) };
+});
+
+vi.mock("@homarr/request-handler/calendar", () => ({
+ calendarMonthRequestHandler: { handler },
+}));
+
+const createCallerAsync = async () => {
+ const db = createDb();
+ const userId = createId();
+ const integrationId = createId();
+ await db.insert(users).values({ id: userId });
+ await db.insert(integrations).values({
+ id: integrationId,
+ kind: "radarr",
+ name: "Radarr",
+ url: "https://radarr.example.com",
+ });
+ await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
+ const session = {
+ user: { id: userId, permissions: [], colorScheme: "light" },
+ expires: new Date().toISOString(),
+ } satisfies Session;
+ return { caller: calendarRouter.createCaller({ db, deviceType: undefined, session }), integrationId };
+};
+
+const createEvent = (title: string, releaseType?: "digitalRelease" | "physicalRelease") => ({
+ title,
+ subTitle: null,
+ description: null,
+ startDate: new Date("2025-05-10T12:00:00.000Z"),
+ endDate: null,
+ image: null,
+ location: null,
+ indicatorColor: "blue",
+ links: [],
+ ...(releaseType ? { metadata: { type: "radarr" as const, releaseType } } : {}),
+});
+
+describe("calendarRouter.findAllEvents", () => {
+ test("uses human month numbers and filters Radarr release types", async () => {
+ const { caller, integrationId } = await createCallerAsync();
+ getDataAsync.mockResolvedValueOnce({
+ data: [
+ createEvent("Digital", "digitalRelease"),
+ createEvent("Physical", "physicalRelease"),
+ createEvent("Sonarr episode"),
+ ],
+ timestamp: new Date(),
+ });
+
+ const result = await caller.findAllEvents({
+ integrationIds: [integrationId],
+ year: 2025,
+ month: 5,
+ releaseType: ["digitalRelease"],
+ showUnmonitored: true,
+ });
+
+ expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: integrationId }), {
+ year: 2025,
+ month: 5,
+ releaseType: ["digitalRelease"],
+ showUnmonitored: true,
+ });
+ expect(result[0]?.events.map((event) => event.title)).toEqual(["Digital", "Sonarr episode"]);
+ });
+
+ test("rejects zero-based and out-of-range months", async () => {
+ const { caller } = await createCallerAsync();
+ await expect(
+ caller.findAllEvents({
+ integrationIds: [],
+ year: 2025,
+ month: 0,
+ releaseType: [],
+ showUnmonitored: true,
+ }),
+ ).rejects.toThrow();
+ });
+
+ test("rejects the query when every selected calendar fails", async () => {
+ const { caller, integrationId } = await createCallerAsync();
+ getDataAsync.mockRejectedValueOnce(new Error("remote calendar unavailable"));
+
+ await expect(
+ caller.findAllEvents({
+ integrationIds: [integrationId],
+ year: 2025,
+ month: 5,
+ releaseType: ["inCinemas", "digitalRelease", "physicalRelease"],
+ showUnmonitored: true,
+ }),
+ ).rejects.toThrow("All integration queries failed");
+ });
+});
diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts
index 8e9ee31ef..bbe5b247d 100644
--- a/packages/api/src/router/widgets/calendar.ts
+++ b/packages/api/src/router/widgets/calendar.ts
@@ -14,13 +14,13 @@ export const calendarRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
- "Get calendar events for upcoming and recent media releases. REQUIRED: integrationIds (array of integration IDs from integration_all, filter by kind sonarr/radarr/lidarr/readarr), year (number), month (number), releaseType (array of 'inCinemas'|'digitalRelease'|'physicalRelease'), showUnmonitored (boolean). Great for 'what's coming out this week/month?'",
+ "Get calendar events from calendar-capable integrations. REQUIRED: integrationIds (IDs from integration_all whose kinds include Sonarr, Radarr, Lidarr, Readarr, Home Assistant, Nextcloud, or iCal), year (four-digit number), month (human month number from 1=January through 12=December), releaseType (Radarr filters: 'inCinemas'|'digitalRelease'|'physicalRelease'), showUnmonitored (boolean). Great for 'what's coming out this week/month?'",
},
})
.input(
z.object({
- year: z.number(),
- month: z.number(),
+ year: z.number().int().min(1970).max(9999),
+ month: z.number().int().min(1).max(12),
releaseType: z.array(z.enum(radarrReleaseTypes)),
showUnmonitored: z.boolean(),
}),
@@ -33,9 +33,12 @@ export const calendarRouter = createTRPCRouter({
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const { data } = await innerHandler.getDataAsync();
+ const events = data.filter(
+ (event) => event.metadata?.type !== "radarr" || input.releaseType.includes(event.metadata.releaseType),
+ );
return {
- events: data,
+ events,
integration: {
id: integration.id,
name: integration.name,
diff --git a/packages/api/src/test/assistant-generation-access.spec.ts b/packages/api/src/test/assistant-generation-access.spec.ts
new file mode 100644
index 000000000..ec9b26b4f
--- /dev/null
+++ b/packages/api/src/test/assistant-generation-access.spec.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test, vi } from "vitest";
+
+vi.mock("@homarr/common/env", () => ({
+ env: { SECRET_ENCRYPTION_KEY: "a".repeat(64) },
+}));
+
+import {
+ createAssistantGenerationAccessToken,
+ verifyAssistantGenerationAccessToken,
+} from "../assistant-generation-access";
+
+describe("assistant generation access token", () => {
+ const input = {
+ userId: "user-1",
+ threadId: "thread-1",
+ generationId: "gen-example",
+ };
+
+ test("binds a generation to its user and conversation", () => {
+ const accessToken = createAssistantGenerationAccessToken(input);
+
+ expect(accessToken).toMatch(/^[A-Za-z0-9_-]{43}$/);
+ expect(verifyAssistantGenerationAccessToken(input, accessToken)).toBe(true);
+ expect(verifyAssistantGenerationAccessToken({ ...input, userId: "user-2" }, accessToken)).toBe(false);
+ expect(verifyAssistantGenerationAccessToken({ ...input, threadId: "thread-2" }, accessToken)).toBe(false);
+ expect(verifyAssistantGenerationAccessToken({ ...input, generationId: "gen-other" }, accessToken)).toBe(false);
+ });
+});
diff --git a/packages/api/src/test/assistant-generation-telemetry.spec.ts b/packages/api/src/test/assistant-generation-telemetry.spec.ts
new file mode 100644
index 000000000..480a29b45
--- /dev/null
+++ b/packages/api/src/test/assistant-generation-telemetry.spec.ts
@@ -0,0 +1,105 @@
+import { describe, expect, test, vi } from "vitest";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
+
+import {
+ fetchOpenRouterGenerationTelemetryAsync,
+ parseOpenRouterGenerationTelemetry,
+} from "../assistant-generation-telemetry";
+
+describe("parseOpenRouterGenerationTelemetry", () => {
+ test("uses provider-native tokens for context and throughput", () => {
+ expect(
+ parseOpenRouterGenerationTelemetry({
+ data: {
+ id: "gen-1",
+ provider_name: "Alibaba",
+ latency: 964,
+ generation_time: 1451,
+ moderation_latency: 12,
+ tokens_prompt: 13_343,
+ tokens_completion: 62,
+ native_tokens_prompt: 12_777,
+ native_tokens_completion: 55,
+ native_tokens_cached: 2048,
+ native_tokens_reasoning: 7,
+ total_cost: 0.00041,
+ upstream_inference_cost: 0.00039,
+ cache_discount: 0.00003,
+ finish_reason: "stop",
+ native_finish_reason: "stop",
+ service_tier: "default",
+ data_region: "US",
+ provider_responses: [
+ { status: 429, latency: 620 },
+ { status: 200, latency: 964 },
+ ],
+ is_byok: false,
+ streamed: true,
+ cancelled: false,
+ },
+ }),
+ ).toMatchObject({
+ generationId: "gen-1",
+ routedProvider: "Alibaba",
+ providerLatencyMs: 964,
+ generationTimeMs: 1451,
+ providerOutputTokensPerSecond: 37.90489317711923,
+ inputTokens: 12_777,
+ outputTokens: 55,
+ normalizedInputTokens: 13_343,
+ normalizedOutputTokens: 62,
+ cachedInputTokens: 2048,
+ reasoningTokens: 7,
+ cost: 0.00041,
+ upstreamCost: 0.00039,
+ cacheDiscount: 0.00003,
+ fallbackCount: 1,
+ fallbackLatencyMs: 620,
+ serviceTier: "default",
+ dataRegion: "US",
+ });
+ });
+
+ test("returns null for malformed generation data", () => {
+ expect(parseOpenRouterGenerationTelemetry(null)).toBeNull();
+ expect(parseOpenRouterGenerationTelemetry({ data: [] })).toBeNull();
+ expect(parseOpenRouterGenerationTelemetry({ data: { latency: -1, total_cost: "invalid" } })).toBeNull();
+ });
+
+ test("does not count additional successful provider responses as fallbacks", () => {
+ expect(
+ parseOpenRouterGenerationTelemetry({
+ id: "gen-success",
+ provider_responses: [
+ { status: 200, latency: 100 },
+ { status: 200, latency: 120 },
+ ],
+ }),
+ ).toMatchObject({ fallbackCount: 0 });
+ });
+
+ test("caches completed generation records", async () => {
+ const mockedFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
+ mockedFetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ data: { id: "gen-cached", native_tokens_completion: 4 } }),
+ } as Awaited>);
+ const input = {
+ baseUrl: "https://openrouter.ai/api/v1",
+ generationId: "gen-cached",
+ headers: { Authorization: "Bearer test" },
+ };
+
+ await expect(fetchOpenRouterGenerationTelemetryAsync(input)).resolves.toMatchObject({
+ generationId: "gen-cached",
+ outputTokens: 4,
+ });
+ await expect(fetchOpenRouterGenerationTelemetryAsync(input)).resolves.toMatchObject({
+ generationId: "gen-cached",
+ });
+ expect(mockedFetch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/api/src/test/assistant-message-order.spec.ts b/packages/api/src/test/assistant-message-order.spec.ts
new file mode 100644
index 000000000..83aaa3d9d
--- /dev/null
+++ b/packages/api/src/test/assistant-message-order.spec.ts
@@ -0,0 +1,89 @@
+import { describe, expect, test } from "vitest";
+
+import { orderMessagesByParent } from "../assistant-message-order";
+
+const message = (id: string, parentId: string | null) => ({ id, parentId });
+
+describe("orderMessagesByParent", () => {
+ test("keeps an already correct order untouched", () => {
+ const messages = [message("a", null), message("b", "a"), message("c", "b")];
+
+ expect(orderMessagesByParent(messages).map((entry) => entry.id)).toEqual(["a", "b", "c"]);
+ });
+
+ test("moves a child that the database returned before its parent", () => {
+ // Happens when created_at ties on MySQL/SQLite, which store whole seconds only.
+ const messages = [message("c", "b"), message("a", null), message("b", "a")];
+
+ expect(orderMessagesByParent(messages).map((entry) => entry.id)).toEqual(["a", "b", "c"]);
+ });
+
+ test("preserves sibling order from the incoming list", () => {
+ const messages = [message("root", null), message("first", "root"), message("second", "root")];
+
+ expect(orderMessagesByParent(messages).map((entry) => entry.id)).toEqual(["root", "first", "second"]);
+ });
+
+ test("keeps branches grouped under their parent", () => {
+ const messages = [
+ message("root", null),
+ message("branch-a", "root"),
+ message("branch-b", "root"),
+ message("branch-a-child", "branch-a"),
+ ];
+
+ expect(orderMessagesByParent(messages).map((entry) => entry.id)).toEqual([
+ "root",
+ "branch-a",
+ "branch-a-child",
+ "branch-b",
+ ]);
+ });
+
+ test("treats a dangling parent reference as a root instead of dropping the message", () => {
+ const messages = [message("orphan", "deleted-parent"), message("root", null)];
+
+ expect(orderMessagesByParent(messages).map((entry) => entry.id)).toEqual(["orphan", "root"]);
+ });
+
+ test("does not lose messages that form a cycle", () => {
+ const messages = [message("a", "b"), message("b", "a")];
+
+ expect(
+ orderMessagesByParent(messages)
+ .map((entry) => entry.id)
+ .toSorted(),
+ ).toEqual(["a", "b"]);
+ });
+
+ test("keeps a child of a cycle behind its parent", () => {
+ // `child` hangs off the cycle a -> b -> a. Only the cycle's own back-edge can break the
+ // parent-before-child contract; `child` must still come after `a`.
+ const messages = [message("child", "a"), message("a", "b"), message("b", "a")];
+
+ const ordered = orderMessagesByParent(messages).map((entry) => entry.id);
+
+ expect(ordered.toSorted()).toEqual(["a", "b", "child"]);
+ expect(ordered.indexOf("a")).toBeLessThan(ordered.indexOf("child"));
+ });
+
+ test("handles a chain deeper than the call stack", () => {
+ // `getThread` loads every message in a thread, and a conversation is one long parent chain, so
+ // a recursive walk would blow the stack on a long thread.
+ const depth = 50_000;
+ const messages = Array.from({ length: depth }, (_, index) =>
+ message(`m${index}`, index === 0 ? null : `m${index - 1}`),
+ );
+
+ const ordered = orderMessagesByParent(messages);
+
+ expect(ordered).toHaveLength(depth);
+ expect(ordered[0]?.id).toBe("m0");
+ expect(ordered[depth - 1]?.id).toBe(`m${depth - 1}`);
+ });
+
+ test("returns short lists unchanged", () => {
+ expect(orderMessagesByParent([])).toEqual([]);
+ expect(orderMessagesByParent([message("only", null)]).map((entry) => entry.id)).toEqual(["only"]);
+ });
+});
diff --git a/packages/api/src/test/assistant-providers.spec.ts b/packages/api/src/test/assistant-providers.spec.ts
new file mode 100644
index 000000000..751a2f5db
--- /dev/null
+++ b/packages/api/src/test/assistant-providers.spec.ts
@@ -0,0 +1,65 @@
+import { describe, expect, test } from "vitest";
+
+import {
+ assistantProviderCanUseOpenRouterServerTools,
+ assistantProviderIds,
+ assistantProviderPresets,
+ assistantProviderRequiresApiKey,
+ getAssistantModelOptionLabel,
+ resolveAssistantModelId,
+} from "@homarr/definitions";
+
+describe("assistant provider presets", () => {
+ test("defines one complete preset for every provider", () => {
+ expect(Object.keys(assistantProviderPresets)).toEqual(assistantProviderIds);
+ expect(new Set(assistantProviderIds).size).toBe(assistantProviderIds.length);
+ });
+
+ test.each(assistantProviderIds.filter((provider) => provider !== "custom"))(
+ "%s has a valid default endpoint",
+ (provider) => {
+ expect(() => new URL(assistantProviderPresets[provider].baseUrl)).not.toThrow();
+ expect(assistantProviderPresets[provider].modelDiscoveryPath).toMatch(/^\/[^/]/);
+ },
+ );
+
+ test("requires credentials for hosted providers but not local or custom endpoints", () => {
+ for (const provider of assistantProviderIds) {
+ expect(assistantProviderRequiresApiKey(provider)).toBe(assistantProviderPresets[provider].category === "hosted");
+ }
+ });
+
+ test.each(assistantProviderIds.filter((provider) => provider !== "custom"))(
+ "%s has a valid provider mark",
+ (provider) => {
+ const iconUrl = assistantProviderPresets[provider].iconUrl;
+ expect(iconUrl).not.toBeNull();
+ expect(() => new URL(iconUrl ?? "")).not.toThrow();
+ },
+ );
+
+ test("uses Groq's high-contrast LobeHub mark in dark mode", () => {
+ expect(assistantProviderPresets.groq.darkIconUrl).toBe(
+ "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@latest/dark/groq.png",
+ );
+ });
+
+ test("keeps the display label separate from the provider model ID", () => {
+ const model = {
+ id: "deepseek/deepseek-v4-pro",
+ name: "DeepSeek: DeepSeek V4 Pro",
+ };
+
+ expect(getAssistantModelOptionLabel(model)).toBe("DeepSeek: DeepSeek V4 Pro (deepseek/deepseek-v4-pro)");
+ expect(resolveAssistantModelId([model], model.id)).toBe(model.id);
+ expect(resolveAssistantModelId([model], getAssistantModelOptionLabel(model))).toBe(model.id);
+ });
+
+ test("allows OpenRouter server tools for OpenRouter, its Homarr proxy, and explicit custom proxies", () => {
+ expect(assistantProviderCanUseOpenRouterServerTools("openrouter")).toBe(true);
+ expect(assistantProviderCanUseOpenRouterServerTools("homarr")).toBe(true);
+ expect(assistantProviderCanUseOpenRouterServerTools("custom")).toBe(true);
+ expect(assistantProviderCanUseOpenRouterServerTools("openai")).toBe(false);
+ expect(assistantProviderCanUseOpenRouterServerTools("anthropic")).toBe(false);
+ });
+});
diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts
index 5df3a9f06..43a812c5b 100644
--- a/packages/api/src/test/mcp.spec.ts
+++ b/packages/api/src/test/mcp.spec.ts
@@ -1,8 +1,9 @@
import type { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
-import { extractToolsFromProcedures } from "trpc-to-mcp";
+import { z } from "zod/v4";
import { mcpRouter } from "../mcp";
+import { extractMcpToolsFromProcedures } from "../mcp-tools";
vi.mock("@homarr/auth", () => ({}));
@@ -18,9 +19,14 @@ const MCP_TOOL_ALLOWLIST = {
"beszel_getSystems",
"beszel_getSystemStats",
"board_getAllBoards",
+ "board_getBoardSettings",
"calendar_findAllEvents",
"customWidget_get",
"customWidget_getAuthoringPrompt",
+ "customWidget_getComponent",
+ "customWidget_getComponentCatalog",
+ "customWidget_getExample",
+ "customWidget_getSharedProps",
"customWidget_getSkill",
"customWidget_legacyMigrationPrompt",
"customWidget_list",
@@ -67,6 +73,7 @@ const MCP_TOOL_ALLOWLIST = {
"board_deleteBoard",
"board_duplicateBoard",
"board_renameBoard",
+ "board_resetLayout",
"board_savePartialBoardSettings",
"board_setHomeBoard",
"board_setMobileHomeBoard",
@@ -123,7 +130,7 @@ function actualToolInventory() {
string,
{ _def?: { type?: "query" | "mutation" } }
>;
- return extractToolsFromProcedures(mcpRouter)
+ return extractMcpToolsFromProcedures(mcpRouter)
.map((tool) => {
const procedure = procedures[tool.pathInRouter.join(".")];
const type = procedure?.["_def"]?.type;
@@ -142,7 +149,7 @@ describe("production MCP router", () => {
});
test("keeps every secret-bearing tool behind a mutation procedure", () => {
- const tools = extractToolsFromProcedures(mcpRouter);
+ const tools = extractMcpToolsFromProcedures(mcpRouter);
const procedures = mcpRouter["_def"].procedures as unknown as Record<
string,
{ _def?: { type?: "query" | "mutation" } }
@@ -156,7 +163,7 @@ describe("production MCP router", () => {
});
test("gives every production tool a description", () => {
- const tools = extractToolsFromProcedures(mcpRouter);
+ const tools = extractMcpToolsFromProcedures(mcpRouter);
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
"Get the current Custom Widget authoring instructions.",
@@ -164,6 +171,14 @@ describe("production MCP router", () => {
});
});
+test("MCP tools are deterministically ordered and retain executable schemas", () => {
+ const tools = extractMcpToolsFromProcedures(mcpRouter);
+ const toolNames = tools.map((tool) => tool.name);
+
+ expect(toolNames).toEqual(toolNames.toSorted((left, right) => left.localeCompare(right)));
+ expect(tools.every((tool) => tool.inputValidator instanceof z.ZodObject)).toBe(true);
+});
+
describe("custom widget authoring procedure access", () => {
const unauthenticatedCaller = mcpRouter.createCaller({
db: null as never,
@@ -175,6 +190,10 @@ describe("custom widget authoring procedure access", () => {
const calls = [
unauthenticatedCaller.customWidget.schema(),
unauthenticatedCaller.customWidget.getAuthoringPrompt(),
+ unauthenticatedCaller.customWidget.getComponentCatalog(),
+ unauthenticatedCaller.customWidget.getComponent({ name: "Stack" }),
+ unauthenticatedCaller.customWidget.getExample({ name: "service-dashboard" }),
+ unauthenticatedCaller.customWidget.getSharedProps({ names: ["p"] }),
unauthenticatedCaller.customWidget.getSkill(),
unauthenticatedCaller.customWidget.list(),
unauthenticatedCaller.customWidget.validate({ widget: {} }),
@@ -197,6 +216,10 @@ describe("custom widget authoring procedure access", () => {
const calls = [
nonAdminCaller.customWidget.schema(),
nonAdminCaller.customWidget.getAuthoringPrompt(),
+ nonAdminCaller.customWidget.getComponentCatalog(),
+ nonAdminCaller.customWidget.getComponent({ name: "Stack" }),
+ nonAdminCaller.customWidget.getExample({ name: "service-dashboard" }),
+ nonAdminCaller.customWidget.getSharedProps({ names: ["p"] }),
nonAdminCaller.customWidget.getSkill(),
nonAdminCaller.customWidget.secretSet({
definitionId: "widget",
@@ -222,5 +245,27 @@ describe("custom widget authoring procedure access", () => {
await expect(adminCaller.customWidget.getSkill()).resolves.toMatchObject({
name: expect.any(String),
});
+ await expect(adminCaller.customWidget.getComponentCatalog()).resolves.toMatchObject({
+ components: expect.arrayContaining([expect.objectContaining({ name: "Stack" })]),
+ examples: expect.arrayContaining([expect.objectContaining({ id: "service-dashboard" })]),
+ });
+ await expect(adminCaller.customWidget.getComponent({ name: "Stack" })).resolves.toMatchObject({
+ name: "Stack",
+ props: expect.anything(),
+ });
+ await expect(adminCaller.customWidget.getExample({ name: "service-dashboard" })).resolves.toMatchObject({
+ id: "service-dashboard",
+ widget: expect.objectContaining({ $schema: "homarr-custom-widget-v2" }),
+ });
+ await expect(adminCaller.customWidget.getSharedProps({ names: ["p", "m"] })).resolves.toMatchObject({
+ props: [expect.objectContaining({ name: "p" }), expect.objectContaining({ name: "m" })],
+ notFound: [],
+ });
+ await expect(adminCaller.customWidget.getComponent({ name: "NotAComponent" })).rejects.toEqual(
+ expect.objectContaining>({ code: "NOT_FOUND" }),
+ );
+ await expect(adminCaller.customWidget.getExample({ name: "not-an-example" })).rejects.toEqual(
+ expect.objectContaining>({ code: "NOT_FOUND" }),
+ );
});
});
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index b3fe7ae4d..2401a4650 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -8,7 +8,6 @@
*/
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
-import type { McpMeta } from "trpc-to-mcp";
import type { OpenApiMeta } from "trpc-to-openapi";
import { ZodError } from "zod/v4";
@@ -22,6 +21,7 @@ import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
+import type { McpMeta } from "./mcp-tools";
const logger = createLogger({ module: "trpc" });
diff --git a/packages/boards/src/context.tsx b/packages/boards/src/context.tsx
index b6e1ea3a6..2e8137b5e 100644
--- a/packages/boards/src/context.tsx
+++ b/packages/boards/src/context.tsx
@@ -12,6 +12,7 @@ import { updateBoardName } from "./updater";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getBoardByName"];
+ layoutOverrideId: string | null;
currentLayout: string;
initialViewportWidth: number;
} | null>(null);
@@ -41,10 +42,12 @@ export const BoardProvider = ({
initialBoard,
initialLayoutId,
initialViewportWidth,
+ layoutOverrideId = null,
}: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"];
initialLayoutId: string;
initialViewportWidth: number;
+ layoutOverrideId?: string | null;
}>) => {
const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name },
@@ -58,8 +61,8 @@ export const BoardProvider = ({
);
const currentLayout = useSyncExternalStore(
subscribeToViewport,
- () => getCurrentLayout(data),
- () => initialLayoutId,
+ () => getCurrentLayout(data, layoutOverrideId),
+ () => layoutOverrideId ?? initialLayoutId,
);
// Update the board name so it can be used within updateBoard method
@@ -83,6 +86,7 @@ export const BoardProvider = ({
{
return context?.board ?? null;
};
-export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
+export const useLayoutOverride = () => useContext(BoardContext)?.layoutOverrideId ?? null;
+
+export const getCurrentLayout = (
+ board: RouterOutputs["board"]["getBoardByName"],
+ layoutOverrideId: string | null = null,
+) => {
+ const layouts = board.layouts;
+ if (layoutOverrideId && layouts.some((layout) => layout.id === layoutOverrideId)) return layoutOverrideId;
+
const viewportWidth = typeof window === "undefined" ? 0 : getViewportWidth();
- return getLayoutIdForViewportWidth(board.layouts, viewportWidth);
+ return getLayoutIdForViewportWidth(layouts, viewportWidth);
};
export const useCurrentLayout = () => {
diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx
index 9b813b032..58288ce2a 100644
--- a/packages/boards/src/edit-mode.tsx
+++ b/packages/boards/src/edit-mode.tsx
@@ -1,13 +1,18 @@
"use client";
import type { PropsWithChildren } from "react";
-import { createContext, useContext } from "react";
+import { createContext, useContext, useEffect } from "react";
import { useDisclosure } from "@mantine/hooks";
const EditModeContext = createContext | null>(null);
-export const EditModeProvider = ({ children }: PropsWithChildren) => {
- const editModeDisclosure = useDisclosure(false);
+export const EditModeProvider = ({ children, initialOpen = false }: PropsWithChildren<{ initialOpen?: boolean }>) => {
+ const editModeDisclosure = useDisclosure(initialOpen);
+ const [, { open }] = editModeDisclosure;
+
+ useEffect(() => {
+ if (initialOpen) open();
+ }, [initialOpen, open]);
return {children} ;
};
diff --git a/packages/common/src/grid-algorithm.ts b/packages/common/src/grid-algorithm.ts
new file mode 100644
index 000000000..83d165d70
--- /dev/null
+++ b/packages/common/src/grid-algorithm.ts
@@ -0,0 +1,125 @@
+export interface GridAlgorithmItem {
+ id: string;
+ type: "item" | "section";
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ sectionId: string;
+}
+
+interface GridAlgorithmInput {
+ items: GridAlgorithmItem[];
+ width: number;
+ previousWidth: number;
+ sectionId: string;
+}
+
+interface GridAlgorithmOutput {
+ height: number;
+ items: GridAlgorithmItem[];
+}
+
+export const generateResponsiveGridFor = ({
+ items,
+ previousWidth,
+ width,
+ sectionId,
+}: GridAlgorithmInput): GridAlgorithmOutput =>
+ generateResponsiveGridForSection({ items, previousWidth, width, sectionId }, new Set());
+
+const generateResponsiveGridForSection = (
+ { items, previousWidth, width, sectionId }: GridAlgorithmInput,
+ ancestorSectionIds: ReadonlySet,
+): GridAlgorithmOutput => {
+ if (ancestorSectionIds.has(sectionId)) return { height: 0, items: [] };
+
+ const nextAncestorSectionIds = new Set(ancestorSectionIds).add(sectionId);
+ const itemsOfCurrentSection = items
+ .filter((item) => item.sectionId === sectionId)
+ .toSorted((itemA, itemB) =>
+ itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
+ );
+ const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
+
+ if (itemsOfCurrentSection.length === 0) return { height: 0, items: [] };
+
+ const newItems: GridAlgorithmItem[] = [];
+ const dynamicSectionHeightMap = new Map();
+ for (const dynamicSection of normalizedItems.filter((item) => item.type === "section")) {
+ const result = generateResponsiveGridForSection(
+ {
+ items,
+ previousWidth: dynamicSection.previousWidth,
+ width: dynamicSection.width,
+ sectionId: dynamicSection.id,
+ },
+ nextAncestorSectionIds,
+ );
+ newItems.push(...result.items);
+ dynamicSectionHeightMap.set(dynamicSection.id, result.height);
+ }
+
+ if (width >= previousWidth) {
+ return {
+ height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
+ items: newItems.concat(normalizedItems),
+ };
+ }
+
+ const occupied2d: boolean[][] = [];
+ for (const item of normalizedItems) {
+ const itemWithHeight = {
+ ...item,
+ height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
+ };
+ const position = nextFreeSpot(occupied2d, itemWithHeight, width);
+ if (!position) throw new Error("No free spot available");
+
+ addItemToOccupied(occupied2d, itemWithHeight, position, width);
+ newItems.push({ ...itemWithHeight, xOffset: position.x, yOffset: position.y });
+ }
+
+ return { height: occupied2d.length, items: newItems };
+};
+
+const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) =>
+ items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
+
+const addItemToOccupied = (
+ occupied2d: boolean[][],
+ item: GridAlgorithmItem,
+ position: { x: number; y: number },
+ columnCount: number,
+) => {
+ for (let yOffset = 0; yOffset < item.height; yOffset++) {
+ let row = occupied2d[position.y + yOffset];
+ if (!row) {
+ row = Array.from({ length: columnCount }, () => false);
+ occupied2d.push(row);
+ }
+ for (let xOffset = 0; xOffset < item.width; xOffset++) row[position.x + xOffset] = true;
+ }
+};
+
+const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
+ for (let offsetY = 0; offsetY < 99999; offsetY++) {
+ for (let offsetX = 0; offsetX < columnCount; offsetX++) {
+ if (offsetX + item.width <= columnCount && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
+ return { x: offsetX, y: offsetY };
+ }
+ }
+ }
+ return null;
+};
+
+const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
+ for (let yOffset = 0; yOffset < item.height; yOffset++) {
+ const row = occupied2d[position.y + yOffset];
+ if (!row) return true;
+ for (let xOffset = 0; xOffset < item.width; xOffset++) {
+ if (row[position.x + xOffset]) return false;
+ }
+ }
+ return true;
+};
diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts
index 5f82ea6a3..f67a771ad 100644
--- a/packages/common/src/index.ts
+++ b/packages/common/src/index.ts
@@ -11,4 +11,5 @@ export * from "./number";
export * from "./error";
export * from "./theme";
export * from "./function";
+export * from "./grid-algorithm";
export * from "./id";
diff --git a/packages/custom-widgets/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts
index 0a31a7f1c..834f55d29 100644
--- a/packages/custom-widgets/src/core/ai-prompt.ts
+++ b/packages/custom-widgets/src/core/ai-prompt.ts
@@ -127,7 +127,11 @@ export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time.
-Read the live schema and only the component resources needed for the design. Construct a credential-free widget, validate it, create a preview, test queries and simulated actions, visually inspect it, then create it. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. The live resources are authoritative for this Homarr release.
+First call customWidget_getSkill. Its single lazy response contains the complete installed SKILL.md and every bundled reference; follow all of it. Read the live schema and compact component catalog. Fetch only the named component and example resources needed for the design. Component resources contain component-specific props; if shared props are needed, collect their catalog names and fetch those docs together once with customWidget_getSharedProps. The live resources are authoritative for this Homarr release.
+
+Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_create. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate, preview, and create tool inputs.
+
+Do not claim a query works when it has not returned a successful preview response. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. Use simulated preview actions unless the user explicitly enables live preview actions.
Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware.
diff --git a/packages/custom-widgets/src/core/authoring-resources.ts b/packages/custom-widgets/src/core/authoring-resources.ts
index 36667cfea..2d6d4ca41 100644
--- a/packages/custom-widgets/src/core/authoring-resources.ts
+++ b/packages/custom-widgets/src/core/authoring-resources.ts
@@ -1,4 +1,8 @@
-import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "./component-catalog";
+import {
+ customJsxAuthoringCatalog,
+ customJsxCatalogGlobalPropByName,
+ resolveCustomJsxPropDescriptor,
+} from "./component-catalog";
import { customJsxExamples } from "./examples";
import { customJsxTablerIconNames } from "./tabler-icons";
@@ -7,7 +11,58 @@ export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
"https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
-export const CUSTOM_WIDGET_SKILL_VERSION = "2.1.0";
+export const CUSTOM_WIDGET_SKILL_VERSION = "2.2.0";
+
+export const CUSTOM_WIDGET_SKILL_REFERENCES = {
+ "references/schema.md": `# Schema
+
+\`\`\`ts
+interface HomarrCustomWidgetV2 {
+ $schema: "homarr-custom-widget-v2";
+ name: string;
+ description?: string;
+ iconUrl?: string;
+ sources: Record;
+ requests: Record;
+ options?: Record;
+ template: string;
+}
+\`\`\`
+
+Sources require \`default\`. A request defaults to the default source, query, GET, load, inherited auth, and view permission. An action defaults to manual and modify permission. DELETE uses full permission and confirmation.
+
+Use stable real URLs for public APIs and clear suggested URLs for self-hosted services. Homarr collects the installer's server URL, network scope, and credentials as source setup; credentials remain outside the manifest.
+
+Paths use \`{option:name}\` and \`{param:name}\`. Query/body data uses \`{ "$option": "name" }\` and \`{ "$param": "name" }\`. Load queries cannot use params. Runtime parameter names and primitive values are inferred from references.
+
+Every option has \`label\`, \`control\`, and \`default\`. Optional fields are \`description\`, \`choices\`, \`choicesFrom\`, \`min\`, \`max\`, \`step\`, \`advanced\`, and \`group\`.
+`,
+ "references/runtime.md": `# Runtime
+
+Templates read \`data.requestId\`, \`status.requestId\`, \`options.name\`, and temporary \`inputs.name\`. Status contains \`loading\`, \`ok\`, \`status\`, \`statusText\`, and \`error\`.
+
+\`bind="search"\` creates an in-memory input. It is never persisted. Supply invocation values only through \`params\`, for example:
+
+\`\`\`jsx
+
+
+ {(items) => {(items ?? []).map(item => {item.name} )} }
+
+\`\`\`
+
+\`SubFetch\` with \`trigger="manual"\` renders its own load button. To make a card or image launch the request, pass that node through \`triggerContent\` and provide \`triggerAriaLabel\`; Homarr supplies the click and keyboard behavior. Its optional second callback argument contains \`{ ok, status, statusText, loading: false }\`; loading and request failures are rendered by \`SubFetch\` before the callback runs. Do not author \`onClick\` or a fetch callback.
+
+Use expression callbacks for supported collection methods and trusted slots. Do not use callback blocks, IIFEs, authored recursion, or raw events. Bounded regex literals work only with safe string matching/replacement operations.
+`,
+ "references/security.md": `# Security
+
+All requests use Homarr's protected server executor. Source origin, network scope, DNS, redirects, SSRF, rate limits, permissions, size limits, timeouts, and encrypted credential injection remain enforced.
+
+The JSX interpreter blocks imports, hooks, refs, raw event callbacks, browser requests, eval, arbitrary functions, prototype access, unsafe URLs, global CSS escape, arbitrary portals, bigint, statement blocks, IIFEs, and recursion. Regex literals must be bounded and reject backreferences, lookbehind, nested quantifiers, excessive length, and unsupported flags.
+
+Credentials are stored separately and never exported or returned to an agent. A published self-hosted source URL is only a suggestion: installers must confirm or replace private and loopback URLs for their own Homarr deployment. Source origins cannot be controlled through widget options.
+`,
+} as const;
export const CUSTOM_WIDGET_SKILL_MD = `---
name: homarr-custom-widget
@@ -39,11 +94,19 @@ Preview-scoped configuration expires with the preview. To persist user-entered v
When this skill is installed from the repository, optional offline details are in \`references/schema.md\`, \`references/runtime.md\`, and \`references/security.md\`. MCP-only clients should use the live Homarr resources instead.
`;
+const CUSTOM_WIDGET_SKILL_BUNDLE_MD = [
+ CUSTOM_WIDGET_SKILL_MD.trimEnd(),
+ ...Object.entries(CUSTOM_WIDGET_SKILL_REFERENCES).map(
+ ([file, content]) => `\n\n---\n\n# Bundled file: ${file}\n\n${content.trimEnd()}`,
+ ),
+].join("");
+
export function getCustomWidgetSkill() {
return {
name: "homarr-custom-widget",
version: CUSTOM_WIDGET_SKILL_VERSION,
skillMd: CUSTOM_WIDGET_SKILL_MD,
+ references: CUSTOM_WIDGET_SKILL_REFERENCES,
skillsShUrl: CUSTOM_WIDGET_SKILLS_SH_URL,
sourceUrl: CUSTOM_WIDGET_SKILL_SOURCE_URL,
installCommand: CUSTOM_WIDGET_SKILL_INSTALL_COMMAND,
@@ -51,11 +114,28 @@ export function getCustomWidgetSkill() {
}
export function getCustomWidgetSkillContent() {
- return CUSTOM_WIDGET_SKILL_MD;
+ return CUSTOM_WIDGET_SKILL_BUNDLE_MD;
}
export function getCustomWidgetComponentCatalog() {
- return customJsxAuthoringCatalog;
+ return {
+ schemaVersion: customJsxAuthoringCatalog.schemaVersion,
+ mantineVersion: customJsxAuthoringCatalog.mantineVersion,
+ customWidgetVersion: customJsxAuthoringCatalog.customWidgetVersion,
+ components: customJsxAuthoringCatalog.components.map(({ name, category, safety }) => ({ name, category, safety })),
+ sharedProps: {
+ count: customJsxAuthoringCatalog.globalProps.length,
+ names: customJsxAuthoringCatalog.globalProps.map(({ name }) => name),
+ fetchTool: "customWidget_getSharedProps" as const,
+ maxPerRequest: 64,
+ },
+ blockedCapabilities: customJsxAuthoringCatalog.blockedCapabilities,
+ examples: getCustomWidgetExampleCatalog(),
+ };
+}
+
+export function getCustomWidgetExampleCatalog() {
+ return customJsxExamples.map(({ id, title, description }) => ({ id, title, description }));
}
export function getCustomWidgetComponent(name: string) {
@@ -71,17 +151,37 @@ export function getCustomWidgetComponent(name: string) {
category: component.category,
safety: component.safety,
description: component.description,
- props: getCustomJsxComponentProps(canonicalName),
+ props: component.props.map((prop) => resolveCustomJsxPropDescriptor(prop)),
blockedProps: component.blockedProps,
bind: component.bind,
subcomponents: component.subcomponents,
accessibilityRequirements: component.accessibilityRequirements,
documentationUrl: component.documentationUrl,
+ sharedProps:
+ component.safety === "denied"
+ ? undefined
+ : {
+ catalogField: "sharedProps.names" as const,
+ fetchTool: "customWidget_getSharedProps" as const,
+ maxPerRequest: 64,
+ appliesExceptBlockedProps: true as const,
+ },
knownValues: component.name === "TablerIcon" ? { name: customJsxTablerIconNames } : undefined,
deniedReason: component.deniedReason,
};
}
+export function getCustomWidgetSharedProps(names: readonly string[]) {
+ const props: ReturnType[] = [];
+ const notFound: string[] = [];
+ for (const name of new Set(names)) {
+ const prop = customJsxCatalogGlobalPropByName.get(name);
+ if (prop) props.push(resolveCustomJsxPropDescriptor(prop));
+ else notFound.push(name);
+ }
+ return { props, notFound };
+}
+
export function getCustomWidgetExample(name: string) {
return customJsxExamples.find((example) => example.id === name) ?? null;
}
diff --git a/packages/custom-widgets/src/core/custom-jsx-schema.ts b/packages/custom-widgets/src/core/custom-jsx-schema.ts
index cf6f833e8..5e4bfe3b5 100644
--- a/packages/custom-widgets/src/core/custom-jsx-schema.ts
+++ b/packages/custom-widgets/src/core/custom-jsx-schema.ts
@@ -17,6 +17,7 @@ import { getCustomWidgetHttpUrlIssue } from "./url-policy";
export * from "./options-schema";
export * from "./request-schema";
export const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
+const CUSTOM_WIDGET_TEMPLATE_MAX_LENGTH = 50_000;
export const customWidgetBindingIdentifierSchema = z
.string()
@@ -30,7 +31,7 @@ export const customWidgetBindingIdentifierSchema = z
export const customJsxTemplateSchema = z
.string()
.min(1)
- .max(50_000)
+ .max(CUSTOM_WIDGET_TEMPLATE_MAX_LENGTH)
.superRefine((template, ctx) => {
for (const diagnostic of validateCustomJsxTemplate(template)) {
if (diagnostic.severity === "error") {
@@ -193,13 +194,84 @@ function validateTemplateRequests(template: string, requests: Record;
export type HomarrCustomWidgetV2Input = z.input;
-export const customWidgetCreateSchema = customWidgetDefinitionSchema.safeExtend({
+
+const customWidgetTemplateLinesSchema = z
+ .array(z.string().max(10_000))
+ .min(1)
+ .max(2_000)
+ .refine(
+ (lines) => lines.reduce((length, line) => length + line.length, Math.max(0, lines.length - 1)) <= 50_000,
+ `The complete JSX template must contain at most ${CUSTOM_WIDGET_TEMPLATE_MAX_LENGTH} characters`,
+ )
+ .describe(
+ "The complete JSX template split into lines. Prefer this over template for generated multiline JSX. Do not send both fields.",
+ );
+
+export const customWidgetAuthoringDefinitionSchema = z
+ .strictObject({
+ ...customWidgetDefinitionSchema.shape,
+ template: customJsxTemplateSchema
+ .optional()
+ .describe("The complete JSX template as one string. For multiline JSX, prefer templateLines instead."),
+ templateLines: customWidgetTemplateLinesSchema.optional(),
+ })
+ .superRefine((definition, ctx) => {
+ if (definition.template === undefined && definition.templateLines === undefined) {
+ ctx.addIssue({ code: "custom", path: ["template"], message: "Provide template or templateLines" });
+ }
+ if (definition.template !== undefined && definition.templateLines !== undefined) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["templateLines"],
+ message: "Provide template or templateLines, not both",
+ });
+ }
+ });
+
+export type CustomWidgetAuthoringDefinitionInput = z.input;
+
+export function normalizeCustomWidgetAuthoringDefinition(input: CustomWidgetAuthoringDefinitionInput) {
+ const { templateLines, ...definition } = input;
+ return customWidgetDefinitionSchema.parse({
+ ...definition,
+ template: definition.template ?? templateLines?.join("\n"),
+ });
+}
+
+const customWidgetAuthoringUpdateSchema = z
+ .strictObject({
+ ...customWidgetDefinitionSchema.shape,
+ options: customWidgetOptionsSchema.removeDefault(),
+ templateLines: customWidgetTemplateLinesSchema.optional(),
+ })
+ .partial()
+ .superRefine((definition, ctx) => {
+ if (definition.template !== undefined && definition.templateLines !== undefined) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["templateLines"],
+ message: "Provide template or templateLines, not both",
+ });
+ }
+ });
+
+export type CustomWidgetAuthoringUpdateInput = z.input;
+
+export function normalizeCustomWidgetAuthoringUpdate(input: CustomWidgetAuthoringUpdateInput) {
+ const { templateLines, ...definition } = input;
+ return {
+ ...definition,
+ ...(templateLines === undefined ? {} : { template: templateLines.join("\n") }),
+ };
+}
+
+export const customWidgetCreateSchema = customWidgetAuthoringDefinitionSchema.safeExtend({
secrets: customWidgetSecretsInputSchema.default([]),
});
-export const customWidgetUpdateSchema = z
- .strictObject(customWidgetDefinitionSchema.shape)
- .partial()
- .extend({ id: z.string().min(1), secrets: customWidgetSecretsInputSchema.optional() });
+export const customWidgetUpdateSchema = customWidgetAuthoringUpdateSchema.safeExtend({
+ id: z.string().min(1),
+ secrets: customWidgetSecretsInputSchema.optional(),
+});
export const customWidgetImportSchema = customWidgetDefinitionSchema;
export type CustomWidgetCreateInput = z.infer;
export type CustomWidgetUpdateInput = z.infer;
diff --git a/packages/custom-widgets/src/test/ai-prompt.spec.ts b/packages/custom-widgets/src/test/ai-prompt.spec.ts
index a949ea592..298633f5f 100644
--- a/packages/custom-widgets/src/test/ai-prompt.spec.ts
+++ b/packages/custom-widgets/src/test/ai-prompt.spec.ts
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
-import { buildCustomWidgetAiPrompt, CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION } from "../core/ai-prompt";
+import {
+ buildCustomWidgetAiPrompt,
+ CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION,
+ CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
+} from "../core/ai-prompt";
describe("AI prompt", () => {
it("is self-contained, request-first, and compact", () => {
@@ -36,6 +40,18 @@ describe("AI prompt", () => {
expect(prompt.match(/Recommended components:/gu)).toHaveLength(1);
});
+ it("makes the connected MCP workflow load the complete skill and test every preview query", () => {
+ expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("First call customWidget_getSkill");
+ expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_validate");
+ expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_previewCreate");
+ expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_previewQuery once for every query");
+ expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_getSharedProps");
+ expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines");
+ expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan(
+ CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_create"),
+ );
+ });
+
it("redacts secrets and preserves the final instruction when optional context is large", () => {
const prompt = buildCustomWidgetAiPrompt(
undefined,
diff --git a/packages/custom-widgets/src/test/authoring-resources.spec.ts b/packages/custom-widgets/src/test/authoring-resources.spec.ts
index b353f4b6d..3417b84a0 100644
--- a/packages/custom-widgets/src/test/authoring-resources.spec.ts
+++ b/packages/custom-widgets/src/test/authoring-resources.spec.ts
@@ -5,28 +5,73 @@ import { describe, expect, it } from "vitest";
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
+ getCustomWidgetSharedProps,
getCustomWidgetSkill,
getCustomWidgetSkillContent,
} from "../core/authoring-resources";
+const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.agents/skills/homarr-custom-widget");
+
describe("authoring resources", () => {
- it("returns the concise skills.sh discovery shape", () => {
+ it("returns the complete portable skill and keeps every bundled source file in sync", () => {
const skill = getCustomWidgetSkill();
- expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.1.0" });
+ expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.2.0" });
expect(skill.skillsShUrl).toContain("skills.sh/homarr-labs/homarr");
expect(skill.sourceUrl).toContain("/tree/HEAD/.agents/skills/homarr-custom-widget");
expect(skill.installCommand).toContain("--skill homarr-custom-widget");
- expect(skill).not.toHaveProperty("files");
expect(skill).not.toHaveProperty("componentCatalog");
- expect(getCustomWidgetSkillContent()).toBe(skill.skillMd);
- expect(skill.skillMd.trim()).toBe(
- readFileSync(path.join(process.cwd(), ".agents/skills/homarr-custom-widget/SKILL.md"), "utf8").trim(),
- );
+ expect(skill).not.toHaveProperty("entrypointMd");
+ expect(skill).not.toHaveProperty("bundledMarkdown");
+ expect(skill.skillMd.trim()).toBe(readFileSync(path.join(skillSourceDirectory, "SKILL.md"), "utf8").trim());
+ const bundledContent = getCustomWidgetSkillContent();
+ for (const [file, content] of Object.entries(skill.references)) {
+ expect(content.trim()).toBe(readFileSync(path.join(skillSourceDirectory, file), "utf8").trim());
+ expect(bundledContent).toContain(`# Bundled file: ${file}`);
+ expect(bundledContent).toContain(content.trim());
+ }
+ expect(bundledContent).toContain(skill.skillMd.trim());
+ expect(Buffer.byteLength(JSON.stringify(skill), "utf8")).toBeLessThan(12_000);
});
- it("keeps the full catalog retrieval-based", () => {
- expect(getCustomWidgetComponentCatalog().components.length).toBeGreaterThan(100);
- expect(getCustomWidgetComponent("Stack")?.name).toBe("Stack");
+ it("keeps catalog discovery compact and component details lazy", () => {
+ const catalog = getCustomWidgetComponentCatalog();
+ expect(catalog.components.length).toBeGreaterThan(100);
+ expect(catalog.components).toEqual(
+ expect.arrayContaining([expect.objectContaining({ name: "Stack", category: expect.any(String) })]),
+ );
+ expect(catalog.examples).toEqual(expect.arrayContaining([expect.objectContaining({ id: "service-dashboard" })]));
+ expect(catalog.sharedProps).toMatchObject({
+ count: expect.any(Number),
+ fetchTool: "customWidget_getSharedProps",
+ maxPerRequest: 64,
+ });
+ expect(catalog.sharedProps.names).toContain("p");
+ expect(Buffer.byteLength(JSON.stringify(catalog), "utf8")).toBeLessThan(45_000);
+
+ const stack = getCustomWidgetComponent("Stack");
+ expect(stack).toMatchObject({
+ name: "Stack",
+ sharedProps: { fetchTool: "customWidget_getSharedProps" },
+ });
+ expect(stack?.props.some(({ source }) => source === "global")).toBe(false);
+ expect(Buffer.byteLength(JSON.stringify(stack), "utf8")).toBeLessThan(20_000);
expect(getCustomWidgetComponent("Icon")?.knownValues?.name.length).toBeGreaterThan(100);
+
+ const largestComponentPayload = Math.max(
+ ...catalog.components.map(({ name }) =>
+ Buffer.byteLength(JSON.stringify(getCustomWidgetComponent(name)), "utf8"),
+ ),
+ );
+ expect(largestComponentPayload).toBeLessThan(20_000);
+ });
+
+ it("returns only requested shared prop documentation in one bounded payload", () => {
+ const result = getCustomWidgetSharedProps(["p", "m", "not-a-shared-prop", "p"]);
+ expect(result.props.map(({ name }) => name)).toEqual(["p", "m"]);
+ expect(result.notFound).toEqual(["not-a-shared-prop"]);
+ expect(Buffer.byteLength(JSON.stringify(result), "utf8")).toBeLessThan(4_000);
+
+ const boundedBatch = getCustomWidgetSharedProps(getCustomWidgetComponentCatalog().sharedProps.names.slice(0, 64));
+ expect(Buffer.byteLength(JSON.stringify(boundedBatch), "utf8")).toBeLessThan(15_000);
});
});
diff --git a/packages/custom-widgets/src/test/options.spec.ts b/packages/custom-widgets/src/test/options.spec.ts
index 078cfc62a..628246b7a 100644
--- a/packages/custom-widgets/src/test/options.spec.ts
+++ b/packages/custom-widgets/src/test/options.spec.ts
@@ -71,17 +71,16 @@ describe("lean Custom Widget options", () => {
});
it("replaces stale values with current defaults", () => {
- const options = customWidgetDefinitionSchema.parse({
- $schema: "homarr-custom-widget-v2",
- name: "Updated options",
- sources: {},
- requests: {},
- options: {
- limit: { label: "Limit", control: "slider", default: 5, min: 1, max: 10 },
- label: { label: "Label", control: "text", default: "Default" },
- },
- template: "{options.limit} ",
- }).options;
+ const options = {
+ limit: customWidgetOptionSchema.parse({
+ label: "Limit",
+ control: "slider",
+ default: 5,
+ min: 1,
+ max: 10,
+ }),
+ label: customWidgetOptionSchema.parse({ label: "Label", control: "text", default: "Default" }),
+ };
const normalized = normalizeCustomWidgetOptions(options, {
limit: "5",
diff --git a/packages/custom-widgets/src/test/schema.spec.ts b/packages/custom-widgets/src/test/schema.spec.ts
index eeb02a262..3f1cc093d 100644
--- a/packages/custom-widgets/src/test/schema.spec.ts
+++ b/packages/custom-widgets/src/test/schema.spec.ts
@@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest";
import {
CUSTOM_WIDGET_STARTER,
+ customWidgetAuthoringDefinitionSchema,
customWidgetDefinitionSchema,
+ customWidgetUpdateSchema,
customJsxRequestSchema,
getCustomWidgetDefaultOptions,
getCustomWidgetSecretRequirements,
getCustomWidgetSourceSetups,
hasSameCustomWidgetSourceAuthentication,
applyCustomWidgetSourceSetup,
+ normalizeCustomWidgetAuthoringDefinition,
} from "../core";
describe("lean Custom Widget schema", () => {
@@ -61,6 +64,56 @@ describe("lean Custom Widget schema", () => {
expect(customWidgetDefinitionSchema.parse(withoutOptions).options).toEqual({});
});
+ it("accepts and normalizes realistic multiline JSX without JSON string escaping", () => {
+ const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
+ const templateLines = [
+ '',
+ ' ',
+ " Upcoming fixtures ",
+ ' {(data.fixtures ?? []).length} matches ',
+ " ",
+ " {(data.fixtures ?? []).map(fixture => (",
+ ' ',
+ " {fixture.home_team.name} vs {fixture.away_team.name} ",
+ " ",
+ " ))}",
+ " ",
+ ];
+ const candidate = customWidgetAuthoringDefinitionSchema.parse({
+ ...definition,
+ requests: { fixtures: { path: "/fixtures" } },
+ templateLines,
+ });
+
+ expect(normalizeCustomWidgetAuthoringDefinition(candidate).template).toBe(templateLines.join("\n"));
+ });
+
+ it("requires exactly one authoring template representation", () => {
+ expect(
+ customWidgetAuthoringDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, templateLines: [" "] })
+ .success,
+ ).toBe(false);
+ const { template: _template, ...withoutTemplate } = CUSTOM_WIDGET_STARTER;
+ expect(customWidgetAuthoringDefinitionSchema.safeParse(withoutTemplate).success).toBe(false);
+ });
+
+ it("keeps omitted update fields absent when using templateLines", () => {
+ expect(customWidgetUpdateSchema.parse({ id: "widget", templateLines: [" "] })).toEqual({
+ id: "widget",
+ templateLines: [" "],
+ });
+ });
+
+ it("applies the template size limit before joining templateLines", () => {
+ const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
+ expect(
+ customWidgetAuthoringDefinitionSchema.safeParse({
+ ...definition,
+ templateLines: Array.from({ length: 6 }, () => "x".repeat(10_000)),
+ }).success,
+ ).toBe(false);
+ });
+
it("rejects source IDs that collide case-insensitively", () => {
const result = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
diff --git a/packages/db/migrations/mysql/0046_add_layout_role.sql b/packages/db/migrations/mysql/0046_add_layout_role.sql
new file mode 100644
index 000000000..46e3c2b6b
--- /dev/null
+++ b/packages/db/migrations/mysql/0046_add_layout_role.sql
@@ -0,0 +1 @@
+ALTER TABLE `layout` ADD `role` varchar(16) DEFAULT 'custom' NOT NULL;
\ No newline at end of file
diff --git a/packages/db/migrations/mysql/0047_add_assistant_tables.sql b/packages/db/migrations/mysql/0047_add_assistant_tables.sql
new file mode 100644
index 000000000..2f04caba9
--- /dev/null
+++ b/packages/db/migrations/mysql/0047_add_assistant_tables.sql
@@ -0,0 +1,38 @@
+CREATE TABLE `assistant_configuration` (
+ `id` varchar(64) NOT NULL DEFAULT 'default',
+ `enabled` boolean NOT NULL DEFAULT false,
+ `web_search_enabled` boolean NOT NULL DEFAULT false,
+ `provider` varchar(32) NOT NULL DEFAULT 'openrouter',
+ `base_url` varchar(2048) NOT NULL DEFAULT 'https://openrouter.ai/api/v1',
+ `model_discovery_path` varchar(512) DEFAULT '/models',
+ `encrypted_api_key` text,
+ `encrypted_headers` text,
+ `model_id` varchar(256),
+ `updated_at` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `assistant_configuration_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `assistant_message` (
+ `id` varchar(128) NOT NULL,
+ `thread_id` varchar(64) NOT NULL,
+ `parent_id` varchar(128),
+ `format` varchar(64) NOT NULL DEFAULT 'ai-sdk/v6',
+ `content` mediumtext NOT NULL,
+ `created_at` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `assistant_message_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `assistant_thread` (
+ `id` varchar(64) NOT NULL,
+ `user_id` varchar(64) NOT NULL,
+ `title` varchar(256),
+ `model_id` varchar(256),
+ `created_at` timestamp NOT NULL DEFAULT (now()),
+ `updated_at` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `assistant_thread_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+ALTER TABLE `assistant_message` ADD CONSTRAINT `assistant_message_thread_id_assistant_thread_id_fk` FOREIGN KEY (`thread_id`) REFERENCES `assistant_thread`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE `assistant_thread` ADD CONSTRAINT `assistant_thread_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX `assistant_message__thread_id_created_at_idx` ON `assistant_message` (`thread_id`,`created_at`);--> statement-breakpoint
+CREATE INDEX `assistant_thread__user_id_updated_at_idx` ON `assistant_thread` (`user_id`,`updated_at`);
\ No newline at end of file
diff --git a/packages/db/migrations/mysql/meta/0046_snapshot.json b/packages/db/migrations/mysql/meta/0046_snapshot.json
new file mode 100644
index 000000000..726c1b491
--- /dev/null
+++ b/packages/db/migrations/mysql/meta/0046_snapshot.json
@@ -0,0 +1,2567 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "d1b4dec4-07ef-498d-9fe0-0c61e93c0664",
+ "prevId": "81b11351-4235-4a61-9a2f-68d01e089c42",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_provider_account_id_pk": {
+ "name": "account_provider_provider_account_id_pk",
+ "columns": ["provider", "provider_account_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apiKey_user_id_user_id_fk": {
+ "name": "apiKey_user_id_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "apiKey_id": {
+ "name": "apiKey_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "app": {
+ "name": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ping_url": {
+ "name": "ping_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "app_id": {
+ "name": "app_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "boardGroupPermission": {
+ "name": "boardGroupPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardGroupPermission_board_id_board_id_fk": {
+ "name": "boardGroupPermission_board_id_board_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardGroupPermission_group_id_group_id_fk": {
+ "name": "boardGroupPermission_group_id_group_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardGroupPermission_board_id_group_id_permission_pk": {
+ "name": "boardGroupPermission_board_id_group_id_permission_pk",
+ "columns": ["board_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "boardUserPermission": {
+ "name": "boardUserPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardUserPermission_board_id_board_id_fk": {
+ "name": "boardUserPermission_board_id_board_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardUserPermission_user_id_user_id_fk": {
+ "name": "boardUserPermission_user_id_user_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardUserPermission_board_id_user_id_permission_pk": {
+ "name": "boardUserPermission_board_id_user_id_permission_pk",
+ "columns": ["board_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "board": {
+ "name": "board",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('fixed')"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('no-repeat')"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('cover')"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('#fa5252')"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('#fd7e14')"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_color": {
+ "name": "icon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_radius": {
+ "name": "item_radius",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('lg')"
+ },
+ "disable_status": {
+ "name": "disable_status",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "board_creator_id_user_id_fk": {
+ "name": "board_creator_id_user_id_fk",
+ "tableFrom": "board",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "board_id": {
+ "name": "board_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "columns": ["name"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "cron_job_configuration": {
+ "name": "cron_job_configuration",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "cron_job_configuration_name": {
+ "name": "cron_job_configuration_name",
+ "columns": ["name"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_v2_definition": {
+ "name": "custom_widget_v2_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requests": {
+ "name": "requests",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "template": {
+ "name": "template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_v2_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_v2_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_v2_definition_id": {
+ "name": "custom_widget_v2_definition_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_v2_secret": {
+ "name": "custom_widget_v2_secret",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_secret_definition_id_fk": {
+ "name": "custom_widget_v2_secret_definition_id_fk",
+ "tableFrom": "custom_widget_v2_secret",
+ "tableTo": "custom_widget_v2_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_v2_secret_definition_id_source_id_kind_pk": {
+ "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk",
+ "columns": ["definition_id", "source_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "groupMember": {
+ "name": "groupMember",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupMember_group_id_group_id_fk": {
+ "name": "groupMember_group_id_group_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "groupMember_user_id_user_id_fk": {
+ "name": "groupMember_user_id_user_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "groupMember_group_id_user_id_pk": {
+ "name": "groupMember_group_id_user_id_pk",
+ "columns": ["group_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "groupPermission": {
+ "name": "groupPermission",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupPermission_group_id_group_id_fk": {
+ "name": "groupPermission_group_id_group_id_fk",
+ "tableFrom": "groupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "group": {
+ "name": "group",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "group_owner_id_user_id_fk": {
+ "name": "group_owner_id_user_id_fk",
+ "tableFrom": "group",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_home_board_id_board_id_fk": {
+ "name": "group_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_mobile_home_board_id_board_id_fk": {
+ "name": "group_mobile_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "group_id": {
+ "name": "group_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "group_name_unique": {
+ "name": "group_name_unique",
+ "columns": ["name"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "iconRepository": {
+ "name": "iconRepository",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "iconRepository_id": {
+ "name": "iconRepository_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "icon": {
+ "name": "icon",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(250)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_repository_id": {
+ "name": "icon_repository_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "icon_icon_repository_id_iconRepository_id_fk": {
+ "name": "icon_icon_repository_id_iconRepository_id_fk",
+ "tableFrom": "icon",
+ "tableTo": "iconRepository",
+ "columnsFrom": ["icon_repository_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "icon_id": {
+ "name": "icon_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integrationGroupPermissions": {
+ "name": "integrationGroupPermissions",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationGroupPermissions_integration_id_integration_id_fk": {
+ "name": "integrationGroupPermissions_integration_id_integration_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationGroupPermissions_group_id_group_id_fk": {
+ "name": "integrationGroupPermissions_group_id_group_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_group_permission__pk": {
+ "name": "integration_group_permission__pk",
+ "columns": ["integration_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integration_item": {
+ "name": "integration_item",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "name": "integration_item_item_id_integration_id_pk",
+ "columns": ["item_id", "integration_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integrationSecret": {
+ "name": "integrationSecret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": ["updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "name": "integrationSecret_integration_id_kind_pk",
+ "columns": ["integration_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integrationUserPermission": {
+ "name": "integrationUserPermission",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationUserPermission_integration_id_integration_id_fk": {
+ "name": "integrationUserPermission_integration_id_integration_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationUserPermission_user_id_user_id_fk": {
+ "name": "integrationUserPermission_user_id_user_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationUserPermission_integration_id_user_id_permission_pk": {
+ "name": "integrationUserPermission_integration_id_user_id_permission_pk",
+ "columns": ["integration_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integration": {
+ "name": "integration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integration_app_id_app_id_fk": {
+ "name": "integration_app_id_app_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "app",
+ "columnsFrom": ["app_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_id": {
+ "name": "integration_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "invite": {
+ "name": "invite",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiration_date": {
+ "name": "expiration_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "invite_creator_id_user_id_fk": {
+ "name": "invite_creator_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "invite_id": {
+ "name": "invite_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "columns": ["token"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "item_layout": {
+ "name": "item_layout",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_layout_item_id_item_id_fk": {
+ "name": "item_layout_item_id_item_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_section_id_section_id_fk": {
+ "name": "item_layout_section_id_section_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_layout_id_layout_id_fk": {
+ "name": "item_layout_layout_id_layout_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_layout_item_id_section_id_layout_id_pk": {
+ "name": "item_layout_item_id_section_id_layout_id_pk",
+ "columns": ["item_id", "section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "item": {
+ "name": "item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ },
+ "advanced_options": {
+ "name": "advanced_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_board_id_board_id_fk": {
+ "name": "item_board_id_board_id_fk",
+ "tableFrom": "item",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_id": {
+ "name": "item_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "layout": {
+ "name": "layout",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "left_gutter_column_count": {
+ "name": "left_gutter_column_count",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "right_gutter_column_count": {
+ "name": "right_gutter_column_count",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "breakpoint": {
+ "name": "breakpoint",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'custom'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "layout_board_id_board_id_fk": {
+ "name": "layout_board_id_board_id_fk",
+ "tableFrom": "layout",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "layout_id": {
+ "name": "layout_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_definition": {
+ "name": "custom_widget_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "header_name": {
+ "name": "header_name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'GET'"
+ },
+ "request_body": {
+ "name": "request_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "display_type": {
+ "name": "display_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'singleValue'"
+ },
+ "display_config": {
+ "name": "display_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_definition_id": {
+ "name": "custom_widget_definition_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_secret": {
+ "name": "custom_widget_secret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "cw_secret_definition_id_cw_definition_id_fk": {
+ "name": "cw_secret_definition_id_cw_definition_id_fk",
+ "tableFrom": "custom_widget_secret",
+ "tableTo": "custom_widget_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_secret_definition_id_kind_pk": {
+ "name": "custom_widget_secret_definition_id_kind_pk",
+ "columns": ["definition_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "media": {
+ "name": "media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "LONGBLOB",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_creator_id_user_id_fk": {
+ "name": "media_creator_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_id": {
+ "name": "media_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "onboarding": {
+ "name": "onboarding",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "step": {
+ "name": "step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "previous_step": {
+ "name": "previous_step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "onboarding_id": {
+ "name": "onboarding_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "search_engine": {
+ "name": "search_engine",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "short": {
+ "name": "short",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url_template": {
+ "name": "url_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'generic'"
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "search_engine_integration_id_integration_id_fk": {
+ "name": "search_engine_integration_id_integration_id_fk",
+ "tableFrom": "search_engine",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "search_engine_id": {
+ "name": "search_engine_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "search_engine_short_unique": {
+ "name": "search_engine_short_unique",
+ "columns": ["short"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "section_collapse_state": {
+ "name": "section_collapse_state",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collapsed": {
+ "name": "collapsed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_collapse_state_user_id_user_id_fk": {
+ "name": "section_collapse_state_user_id_user_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_collapse_state_section_id_section_id_fk": {
+ "name": "section_collapse_state_section_id_section_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_collapse_state_user_id_section_id_pk": {
+ "name": "section_collapse_state_user_id_section_id_pk",
+ "columns": ["user_id", "section_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "section_layout": {
+ "name": "section_layout",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_section_id": {
+ "name": "parent_section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_layout_section_id_section_id_fk": {
+ "name": "section_layout_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_layout_id_layout_id_fk": {
+ "name": "section_layout_layout_id_layout_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_parent_section_id_section_id_fk": {
+ "name": "section_layout_parent_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["parent_section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_layout_section_id_layout_id_pk": {
+ "name": "section_layout_section_id_layout_id_pk",
+ "columns": ["section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "section": {
+ "name": "section",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_id": {
+ "name": "section_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "serverSetting": {
+ "name": "serverSetting",
+ "columns": {
+ "setting_key": {
+ "name": "setting_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "serverSetting_setting_key": {
+ "name": "serverSetting_setting_key",
+ "columns": ["setting_key"]
+ }
+ },
+ "uniqueConstraints": {
+ "serverSetting_settingKey_unique": {
+ "name": "serverSetting_settingKey_unique",
+ "columns": ["setting_key"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "session_session_token": {
+ "name": "session_session_token",
+ "columns": ["session_token"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "trusted_certificate_hostname": {
+ "name": "trusted_certificate_hostname",
+ "columns": {
+ "hostname": {
+ "name": "hostname",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thumbprint": {
+ "name": "thumbprint",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "certificate": {
+ "name": "certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "trusted_certificate_hostname_hostname_thumbprint_pk": {
+ "name": "trusted_certificate_hostname_hostname_thumbprint_pk",
+ "columns": ["hostname", "thumbprint"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'credentials'"
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_search_engine_id": {
+ "name": "default_search_engine_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "open_search_in_new_tab": {
+ "name": "open_search_in_new_tab",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "ddg_bangs": {
+ "name": "ddg_bangs",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "color_scheme": {
+ "name": "color_scheme",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'dark'"
+ },
+ "first_day_of_week": {
+ "name": "first_day_of_week",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "ping_icons_enabled": {
+ "name": "ping_icons_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "enable_right_click_on_widgets": {
+ "name": "enable_right_click_on_widgets",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "completed_manage_tour": {
+ "name": "completed_manage_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "completed_board_tour": {
+ "name": "completed_board_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_home_board_id_board_id_fk": {
+ "name": "user_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_mobile_home_board_id_board_id_fk": {
+ "name": "user_mobile_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_default_search_engine_id_search_engine_id_fk": {
+ "name": "user_default_search_engine_id_search_engine_id_fk",
+ "tableFrom": "user",
+ "tableTo": "search_engine",
+ "columnsFrom": ["default_search_engine_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_id": {
+ "name": "user_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "name": "verificationToken_identifier_token_pk",
+ "columns": ["identifier", "token"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "widget_secret": {
+ "name": "widget_secret",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "widget_secret_item_id_item_id_fk": {
+ "name": "widget_secret_item_id_item_id_fk",
+ "tableFrom": "widget_secret",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "widget_secret_item_id_kind_pk": {
+ "name": "widget_secret_item_id_kind_pk",
+ "columns": ["item_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {}
+ }
+}
diff --git a/packages/db/migrations/mysql/meta/0047_snapshot.json b/packages/db/migrations/mysql/meta/0047_snapshot.json
new file mode 100644
index 000000000..2b9d5d3f5
--- /dev/null
+++ b/packages/db/migrations/mysql/meta/0047_snapshot.json
@@ -0,0 +1,2809 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "5d4a439e-2e7b-4845-a6b4-f24ca5b95408",
+ "prevId": "d1b4dec4-07ef-498d-9fe0-0c61e93c0664",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_provider_account_id_pk": {
+ "name": "account_provider_provider_account_id_pk",
+ "columns": ["provider", "provider_account_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apiKey_user_id_user_id_fk": {
+ "name": "apiKey_user_id_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "apiKey_id": {
+ "name": "apiKey_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "app": {
+ "name": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ping_url": {
+ "name": "ping_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "app_id": {
+ "name": "app_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "assistant_configuration": {
+ "name": "assistant_configuration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'default'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "web_search_enabled": {
+ "name": "web_search_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'openrouter'"
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'https://openrouter.ai/api/v1'"
+ },
+ "model_discovery_path": {
+ "name": "model_discovery_path",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'/models'"
+ },
+ "encrypted_api_key": {
+ "name": "encrypted_api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "encrypted_headers": {
+ "name": "encrypted_headers",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "assistant_configuration_id": {
+ "name": "assistant_configuration_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "assistant_message": {
+ "name": "assistant_message",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ai-sdk/v6'"
+ },
+ "content": {
+ "name": "content",
+ "type": "mediumtext",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "assistant_message__thread_id_created_at_idx": {
+ "name": "assistant_message__thread_id_created_at_idx",
+ "columns": ["thread_id", "created_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assistant_message_thread_id_assistant_thread_id_fk": {
+ "name": "assistant_message_thread_id_assistant_thread_id_fk",
+ "tableFrom": "assistant_message",
+ "tableTo": "assistant_thread",
+ "columnsFrom": ["thread_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "assistant_message_id": {
+ "name": "assistant_message_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "assistant_thread": {
+ "name": "assistant_thread",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ }
+ },
+ "indexes": {
+ "assistant_thread__user_id_updated_at_idx": {
+ "name": "assistant_thread__user_id_updated_at_idx",
+ "columns": ["user_id", "updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assistant_thread_user_id_user_id_fk": {
+ "name": "assistant_thread_user_id_user_id_fk",
+ "tableFrom": "assistant_thread",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "assistant_thread_id": {
+ "name": "assistant_thread_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "boardGroupPermission": {
+ "name": "boardGroupPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardGroupPermission_board_id_board_id_fk": {
+ "name": "boardGroupPermission_board_id_board_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardGroupPermission_group_id_group_id_fk": {
+ "name": "boardGroupPermission_group_id_group_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardGroupPermission_board_id_group_id_permission_pk": {
+ "name": "boardGroupPermission_board_id_group_id_permission_pk",
+ "columns": ["board_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "boardUserPermission": {
+ "name": "boardUserPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardUserPermission_board_id_board_id_fk": {
+ "name": "boardUserPermission_board_id_board_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardUserPermission_user_id_user_id_fk": {
+ "name": "boardUserPermission_user_id_user_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardUserPermission_board_id_user_id_permission_pk": {
+ "name": "boardUserPermission_board_id_user_id_permission_pk",
+ "columns": ["board_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "board": {
+ "name": "board",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('fixed')"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('no-repeat')"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('cover')"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('#fa5252')"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('#fd7e14')"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_color": {
+ "name": "icon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_radius": {
+ "name": "item_radius",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('lg')"
+ },
+ "disable_status": {
+ "name": "disable_status",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "board_creator_id_user_id_fk": {
+ "name": "board_creator_id_user_id_fk",
+ "tableFrom": "board",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "board_id": {
+ "name": "board_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "columns": ["name"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "cron_job_configuration": {
+ "name": "cron_job_configuration",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "cron_job_configuration_name": {
+ "name": "cron_job_configuration_name",
+ "columns": ["name"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_v2_definition": {
+ "name": "custom_widget_v2_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requests": {
+ "name": "requests",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "template": {
+ "name": "template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_v2_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_v2_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_v2_definition_id": {
+ "name": "custom_widget_v2_definition_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_v2_secret": {
+ "name": "custom_widget_v2_secret",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_secret_definition_id_fk": {
+ "name": "custom_widget_v2_secret_definition_id_fk",
+ "tableFrom": "custom_widget_v2_secret",
+ "tableTo": "custom_widget_v2_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_v2_secret_definition_id_source_id_kind_pk": {
+ "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk",
+ "columns": ["definition_id", "source_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "groupMember": {
+ "name": "groupMember",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupMember_group_id_group_id_fk": {
+ "name": "groupMember_group_id_group_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "groupMember_user_id_user_id_fk": {
+ "name": "groupMember_user_id_user_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "groupMember_group_id_user_id_pk": {
+ "name": "groupMember_group_id_user_id_pk",
+ "columns": ["group_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "groupPermission": {
+ "name": "groupPermission",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupPermission_group_id_group_id_fk": {
+ "name": "groupPermission_group_id_group_id_fk",
+ "tableFrom": "groupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "group": {
+ "name": "group",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "group_owner_id_user_id_fk": {
+ "name": "group_owner_id_user_id_fk",
+ "tableFrom": "group",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_home_board_id_board_id_fk": {
+ "name": "group_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_mobile_home_board_id_board_id_fk": {
+ "name": "group_mobile_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "group_id": {
+ "name": "group_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "group_name_unique": {
+ "name": "group_name_unique",
+ "columns": ["name"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "iconRepository": {
+ "name": "iconRepository",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "iconRepository_id": {
+ "name": "iconRepository_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "icon": {
+ "name": "icon",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(250)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_repository_id": {
+ "name": "icon_repository_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "icon_icon_repository_id_iconRepository_id_fk": {
+ "name": "icon_icon_repository_id_iconRepository_id_fk",
+ "tableFrom": "icon",
+ "tableTo": "iconRepository",
+ "columnsFrom": ["icon_repository_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "icon_id": {
+ "name": "icon_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integrationGroupPermissions": {
+ "name": "integrationGroupPermissions",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationGroupPermissions_integration_id_integration_id_fk": {
+ "name": "integrationGroupPermissions_integration_id_integration_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationGroupPermissions_group_id_group_id_fk": {
+ "name": "integrationGroupPermissions_group_id_group_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_group_permission__pk": {
+ "name": "integration_group_permission__pk",
+ "columns": ["integration_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integration_item": {
+ "name": "integration_item",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "name": "integration_item_item_id_integration_id_pk",
+ "columns": ["item_id", "integration_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integrationSecret": {
+ "name": "integrationSecret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": ["updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "name": "integrationSecret_integration_id_kind_pk",
+ "columns": ["integration_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integrationUserPermission": {
+ "name": "integrationUserPermission",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationUserPermission_integration_id_integration_id_fk": {
+ "name": "integrationUserPermission_integration_id_integration_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationUserPermission_user_id_user_id_fk": {
+ "name": "integrationUserPermission_user_id_user_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationUserPermission_integration_id_user_id_permission_pk": {
+ "name": "integrationUserPermission_integration_id_user_id_permission_pk",
+ "columns": ["integration_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integration": {
+ "name": "integration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integration_app_id_app_id_fk": {
+ "name": "integration_app_id_app_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "app",
+ "columnsFrom": ["app_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_id": {
+ "name": "integration_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "invite": {
+ "name": "invite",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiration_date": {
+ "name": "expiration_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "invite_creator_id_user_id_fk": {
+ "name": "invite_creator_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "invite_id": {
+ "name": "invite_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "columns": ["token"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "item_layout": {
+ "name": "item_layout",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_layout_item_id_item_id_fk": {
+ "name": "item_layout_item_id_item_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_section_id_section_id_fk": {
+ "name": "item_layout_section_id_section_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_layout_id_layout_id_fk": {
+ "name": "item_layout_layout_id_layout_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_layout_item_id_section_id_layout_id_pk": {
+ "name": "item_layout_item_id_section_id_layout_id_pk",
+ "columns": ["item_id", "section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "item": {
+ "name": "item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ },
+ "advanced_options": {
+ "name": "advanced_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_board_id_board_id_fk": {
+ "name": "item_board_id_board_id_fk",
+ "tableFrom": "item",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_id": {
+ "name": "item_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "layout": {
+ "name": "layout",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "left_gutter_column_count": {
+ "name": "left_gutter_column_count",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "right_gutter_column_count": {
+ "name": "right_gutter_column_count",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "breakpoint": {
+ "name": "breakpoint",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'custom'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "layout_board_id_board_id_fk": {
+ "name": "layout_board_id_board_id_fk",
+ "tableFrom": "layout",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "layout_id": {
+ "name": "layout_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_definition": {
+ "name": "custom_widget_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "header_name": {
+ "name": "header_name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'GET'"
+ },
+ "request_body": {
+ "name": "request_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "display_type": {
+ "name": "display_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'singleValue'"
+ },
+ "display_config": {
+ "name": "display_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_definition_id": {
+ "name": "custom_widget_definition_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_secret": {
+ "name": "custom_widget_secret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "cw_secret_definition_id_cw_definition_id_fk": {
+ "name": "cw_secret_definition_id_cw_definition_id_fk",
+ "tableFrom": "custom_widget_secret",
+ "tableTo": "custom_widget_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_secret_definition_id_kind_pk": {
+ "name": "custom_widget_secret_definition_id_kind_pk",
+ "columns": ["definition_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "media": {
+ "name": "media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "LONGBLOB",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_creator_id_user_id_fk": {
+ "name": "media_creator_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_id": {
+ "name": "media_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "onboarding": {
+ "name": "onboarding",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "step": {
+ "name": "step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "previous_step": {
+ "name": "previous_step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "onboarding_id": {
+ "name": "onboarding_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "search_engine": {
+ "name": "search_engine",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "short": {
+ "name": "short",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url_template": {
+ "name": "url_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'generic'"
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "search_engine_integration_id_integration_id_fk": {
+ "name": "search_engine_integration_id_integration_id_fk",
+ "tableFrom": "search_engine",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "search_engine_id": {
+ "name": "search_engine_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "search_engine_short_unique": {
+ "name": "search_engine_short_unique",
+ "columns": ["short"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "section_collapse_state": {
+ "name": "section_collapse_state",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collapsed": {
+ "name": "collapsed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_collapse_state_user_id_user_id_fk": {
+ "name": "section_collapse_state_user_id_user_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_collapse_state_section_id_section_id_fk": {
+ "name": "section_collapse_state_section_id_section_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_collapse_state_user_id_section_id_pk": {
+ "name": "section_collapse_state_user_id_section_id_pk",
+ "columns": ["user_id", "section_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "section_layout": {
+ "name": "section_layout",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_section_id": {
+ "name": "parent_section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_layout_section_id_section_id_fk": {
+ "name": "section_layout_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_layout_id_layout_id_fk": {
+ "name": "section_layout_layout_id_layout_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_parent_section_id_section_id_fk": {
+ "name": "section_layout_parent_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["parent_section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_layout_section_id_layout_id_pk": {
+ "name": "section_layout_section_id_layout_id_pk",
+ "columns": ["section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "section": {
+ "name": "section",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_id": {
+ "name": "section_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "serverSetting": {
+ "name": "serverSetting",
+ "columns": {
+ "setting_key": {
+ "name": "setting_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "serverSetting_setting_key": {
+ "name": "serverSetting_setting_key",
+ "columns": ["setting_key"]
+ }
+ },
+ "uniqueConstraints": {
+ "serverSetting_settingKey_unique": {
+ "name": "serverSetting_settingKey_unique",
+ "columns": ["setting_key"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "session_session_token": {
+ "name": "session_session_token",
+ "columns": ["session_token"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "trusted_certificate_hostname": {
+ "name": "trusted_certificate_hostname",
+ "columns": {
+ "hostname": {
+ "name": "hostname",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thumbprint": {
+ "name": "thumbprint",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "certificate": {
+ "name": "certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "trusted_certificate_hostname_hostname_thumbprint_pk": {
+ "name": "trusted_certificate_hostname_hostname_thumbprint_pk",
+ "columns": ["hostname", "thumbprint"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'credentials'"
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_search_engine_id": {
+ "name": "default_search_engine_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "open_search_in_new_tab": {
+ "name": "open_search_in_new_tab",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "ddg_bangs": {
+ "name": "ddg_bangs",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "color_scheme": {
+ "name": "color_scheme",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'dark'"
+ },
+ "first_day_of_week": {
+ "name": "first_day_of_week",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "ping_icons_enabled": {
+ "name": "ping_icons_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "enable_right_click_on_widgets": {
+ "name": "enable_right_click_on_widgets",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "completed_manage_tour": {
+ "name": "completed_manage_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "completed_board_tour": {
+ "name": "completed_board_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_home_board_id_board_id_fk": {
+ "name": "user_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_mobile_home_board_id_board_id_fk": {
+ "name": "user_mobile_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_default_search_engine_id_search_engine_id_fk": {
+ "name": "user_default_search_engine_id_search_engine_id_fk",
+ "tableFrom": "user",
+ "tableTo": "search_engine",
+ "columnsFrom": ["default_search_engine_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_id": {
+ "name": "user_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "name": "verificationToken_identifier_token_pk",
+ "columns": ["identifier", "token"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "widget_secret": {
+ "name": "widget_secret",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "widget_secret_item_id_item_id_fk": {
+ "name": "widget_secret_item_id_item_id_fk",
+ "tableFrom": "widget_secret",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "widget_secret_item_id_kind_pk": {
+ "name": "widget_secret_item_id_kind_pk",
+ "columns": ["item_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {}
+ }
+}
diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json
index 57f1c8ac5..2a3fa36d5 100644
--- a/packages/db/migrations/mysql/meta/_journal.json
+++ b/packages/db/migrations/mysql/meta/_journal.json
@@ -323,6 +323,20 @@
"when": 1786212549933,
"tag": "0045_add_layout_gutters",
"breakpoints": true
+ },
+ {
+ "idx": 46,
+ "version": "5",
+ "when": 1786215199760,
+ "tag": "0046_add_layout_role",
+ "breakpoints": true
+ },
+ {
+ "idx": 47,
+ "version": "5",
+ "when": 1786217088950,
+ "tag": "0047_add_assistant_tables",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/migrations/postgresql/0012_add_layout_role.sql b/packages/db/migrations/postgresql/0012_add_layout_role.sql
new file mode 100644
index 000000000..20c7625be
--- /dev/null
+++ b/packages/db/migrations/postgresql/0012_add_layout_role.sql
@@ -0,0 +1 @@
+ALTER TABLE "layout" ADD COLUMN "role" varchar(16) DEFAULT 'custom' NOT NULL;
\ No newline at end of file
diff --git a/packages/db/migrations/postgresql/0013_add_assistant_tables.sql b/packages/db/migrations/postgresql/0013_add_assistant_tables.sql
new file mode 100644
index 000000000..d094d653c
--- /dev/null
+++ b/packages/db/migrations/postgresql/0013_add_assistant_tables.sql
@@ -0,0 +1,35 @@
+CREATE TABLE "assistant_configuration" (
+ "id" varchar(64) PRIMARY KEY DEFAULT 'default' NOT NULL,
+ "enabled" boolean DEFAULT false NOT NULL,
+ "web_search_enabled" boolean DEFAULT false NOT NULL,
+ "provider" varchar(32) DEFAULT 'openrouter' NOT NULL,
+ "base_url" varchar(2048) DEFAULT 'https://openrouter.ai/api/v1' NOT NULL,
+ "model_discovery_path" varchar(512) DEFAULT '/models',
+ "encrypted_api_key" text,
+ "encrypted_headers" text,
+ "model_id" varchar(256),
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "assistant_message" (
+ "id" varchar(128) PRIMARY KEY NOT NULL,
+ "thread_id" varchar(64) NOT NULL,
+ "parent_id" varchar(128),
+ "format" varchar(64) DEFAULT 'ai-sdk/v6' NOT NULL,
+ "content" text NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "assistant_thread" (
+ "id" varchar(64) PRIMARY KEY NOT NULL,
+ "user_id" varchar(64) NOT NULL,
+ "title" varchar(256),
+ "model_id" varchar(256),
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "assistant_message" ADD CONSTRAINT "assistant_message_thread_id_assistant_thread_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."assistant_thread"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "assistant_thread" ADD CONSTRAINT "assistant_thread_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "assistant_message__thread_id_created_at_idx" ON "assistant_message" USING btree ("thread_id","created_at");--> statement-breakpoint
+CREATE INDEX "assistant_thread__user_id_updated_at_idx" ON "assistant_thread" USING btree ("user_id","updated_at");
\ No newline at end of file
diff --git a/packages/db/migrations/postgresql/meta/0012_snapshot.json b/packages/db/migrations/postgresql/meta/0012_snapshot.json
new file mode 100644
index 000000000..6611b879c
--- /dev/null
+++ b/packages/db/migrations/postgresql/meta/0012_snapshot.json
@@ -0,0 +1,2424 @@
+{
+ "id": "f1d7a91a-bf16-4f26-a0ae-ce77eab2a40e",
+ "prevId": "c0fb2530-8de7-47b6-8229-8e715dc57f09",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_provider_account_id_pk": {
+ "name": "account_provider_provider_account_id_pk",
+ "columns": ["provider", "provider_account_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.apiKey": {
+ "name": "apiKey",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apiKey_user_id_user_id_fk": {
+ "name": "apiKey_user_id_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.app": {
+ "name": "app",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ping_url": {
+ "name": "ping_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.boardGroupPermission": {
+ "name": "boardGroupPermission",
+ "schema": "",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardGroupPermission_board_id_board_id_fk": {
+ "name": "boardGroupPermission_board_id_board_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardGroupPermission_group_id_group_id_fk": {
+ "name": "boardGroupPermission_group_id_group_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardGroupPermission_board_id_group_id_permission_pk": {
+ "name": "boardGroupPermission_board_id_group_id_permission_pk",
+ "columns": ["board_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.boardUserPermission": {
+ "name": "boardUserPermission",
+ "schema": "",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardUserPermission_board_id_board_id_fk": {
+ "name": "boardUserPermission_board_id_board_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardUserPermission_user_id_user_id_fk": {
+ "name": "boardUserPermission_user_id_user_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardUserPermission_board_id_user_id_permission_pk": {
+ "name": "boardUserPermission_board_id_user_id_permission_pk",
+ "columns": ["board_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.board": {
+ "name": "board",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fixed'"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'no-repeat'"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'cover'"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#fa5252'"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#fd7e14'"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_color": {
+ "name": "icon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "item_radius": {
+ "name": "item_radius",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'lg'"
+ },
+ "disable_status": {
+ "name": "disable_status",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "board_creator_id_user_id_fk": {
+ "name": "board_creator_id_user_id_fk",
+ "tableFrom": "board",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cron_job_configuration": {
+ "name": "cron_job_configuration",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_v2_definition": {
+ "name": "custom_widget_v2_definition",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "requests": {
+ "name": "requests",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "template": {
+ "name": "template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_v2_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_v2_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_v2_secret": {
+ "name": "custom_widget_v2_secret",
+ "schema": "",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_secret_definition_id_fk": {
+ "name": "custom_widget_v2_secret_definition_id_fk",
+ "tableFrom": "custom_widget_v2_secret",
+ "tableTo": "custom_widget_v2_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_v2_secret_definition_id_source_id_kind_pk": {
+ "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk",
+ "columns": ["definition_id", "source_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.groupMember": {
+ "name": "groupMember",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupMember_group_id_group_id_fk": {
+ "name": "groupMember_group_id_group_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "groupMember_user_id_user_id_fk": {
+ "name": "groupMember_user_id_user_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "groupMember_group_id_user_id_pk": {
+ "name": "groupMember_group_id_user_id_pk",
+ "columns": ["group_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.groupPermission": {
+ "name": "groupPermission",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupPermission_group_id_group_id_fk": {
+ "name": "groupPermission_group_id_group_id_fk",
+ "tableFrom": "groupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.group": {
+ "name": "group",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "group_owner_id_user_id_fk": {
+ "name": "group_owner_id_user_id_fk",
+ "tableFrom": "group",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_home_board_id_board_id_fk": {
+ "name": "group_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_mobile_home_board_id_board_id_fk": {
+ "name": "group_mobile_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "group_name_unique": {
+ "name": "group_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.iconRepository": {
+ "name": "iconRepository",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.icon": {
+ "name": "icon",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(250)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon_repository_id": {
+ "name": "icon_repository_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "icon_icon_repository_id_iconRepository_id_fk": {
+ "name": "icon_icon_repository_id_iconRepository_id_fk",
+ "tableFrom": "icon",
+ "tableTo": "iconRepository",
+ "columnsFrom": ["icon_repository_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integrationGroupPermissions": {
+ "name": "integrationGroupPermissions",
+ "schema": "",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationGroupPermissions_integration_id_integration_id_fk": {
+ "name": "integrationGroupPermissions_integration_id_integration_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationGroupPermissions_group_id_group_id_fk": {
+ "name": "integrationGroupPermissions_group_id_group_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_group_permission__pk": {
+ "name": "integration_group_permission__pk",
+ "columns": ["integration_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integration_item": {
+ "name": "integration_item",
+ "schema": "",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "name": "integration_item_item_id_integration_id_pk",
+ "columns": ["item_id", "integration_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integrationSecret": {
+ "name": "integrationSecret",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "name": "integrationSecret_integration_id_kind_pk",
+ "columns": ["integration_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integrationUserPermission": {
+ "name": "integrationUserPermission",
+ "schema": "",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationUserPermission_integration_id_integration_id_fk": {
+ "name": "integrationUserPermission_integration_id_integration_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationUserPermission_user_id_user_id_fk": {
+ "name": "integrationUserPermission_user_id_user_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationUserPermission_integration_id_user_id_permission_pk": {
+ "name": "integrationUserPermission_integration_id_user_id_permission_pk",
+ "columns": ["integration_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integration": {
+ "name": "integration",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "integration_app_id_app_id_fk": {
+ "name": "integration_app_id_app_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "app",
+ "columnsFrom": ["app_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invite": {
+ "name": "invite",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expiration_date": {
+ "name": "expiration_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "invite_creator_id_user_id_fk": {
+ "name": "invite_creator_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.item_layout": {
+ "name": "item_layout",
+ "schema": "",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_layout_item_id_item_id_fk": {
+ "name": "item_layout_item_id_item_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_section_id_section_id_fk": {
+ "name": "item_layout_section_id_section_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_layout_id_layout_id_fk": {
+ "name": "item_layout_layout_id_layout_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_layout_item_id_section_id_layout_id_pk": {
+ "name": "item_layout_item_id_section_id_layout_id_pk",
+ "columns": ["item_id", "section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.item": {
+ "name": "item",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ },
+ "advanced_options": {
+ "name": "advanced_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_board_id_board_id_fk": {
+ "name": "item_board_id_board_id_fk",
+ "tableFrom": "item",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.layout": {
+ "name": "layout",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "left_gutter_column_count": {
+ "name": "left_gutter_column_count",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "right_gutter_column_count": {
+ "name": "right_gutter_column_count",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "breakpoint": {
+ "name": "breakpoint",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'custom'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "layout_board_id_board_id_fk": {
+ "name": "layout_board_id_board_id_fk",
+ "tableFrom": "layout",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_definition": {
+ "name": "custom_widget_definition",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "header_name": {
+ "name": "header_name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "method": {
+ "name": "method",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'GET'"
+ },
+ "request_body": {
+ "name": "request_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_type": {
+ "name": "display_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'singleValue'"
+ },
+ "display_config": {
+ "name": "display_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_secret": {
+ "name": "custom_widget_secret",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
+ "name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
+ "tableFrom": "custom_widget_secret",
+ "tableTo": "custom_widget_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_secret_definition_id_kind_pk": {
+ "name": "custom_widget_secret_definition_id_kind_pk",
+ "columns": ["definition_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media": {
+ "name": "media",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "bytea",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_creator_id_user_id_fk": {
+ "name": "media_creator_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.onboarding": {
+ "name": "onboarding",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "step": {
+ "name": "step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "previous_step": {
+ "name": "previous_step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.search_engine": {
+ "name": "search_engine",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "short": {
+ "name": "short",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url_template": {
+ "name": "url_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'generic'"
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "search_engine_integration_id_integration_id_fk": {
+ "name": "search_engine_integration_id_integration_id_fk",
+ "tableFrom": "search_engine",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "search_engine_short_unique": {
+ "name": "search_engine_short_unique",
+ "nullsNotDistinct": false,
+ "columns": ["short"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.section_collapse_state": {
+ "name": "section_collapse_state",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "collapsed": {
+ "name": "collapsed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_collapse_state_user_id_user_id_fk": {
+ "name": "section_collapse_state_user_id_user_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_collapse_state_section_id_section_id_fk": {
+ "name": "section_collapse_state_section_id_section_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_collapse_state_user_id_section_id_pk": {
+ "name": "section_collapse_state_user_id_section_id_pk",
+ "columns": ["user_id", "section_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.section_layout": {
+ "name": "section_layout",
+ "schema": "",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_section_id": {
+ "name": "parent_section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_layout_section_id_section_id_fk": {
+ "name": "section_layout_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_layout_id_layout_id_fk": {
+ "name": "section_layout_layout_id_layout_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_parent_section_id_section_id_fk": {
+ "name": "section_layout_parent_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["parent_section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_layout_section_id_layout_id_pk": {
+ "name": "section_layout_section_id_layout_id_pk",
+ "columns": ["section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.section": {
+ "name": "section",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.serverSetting": {
+ "name": "serverSetting",
+ "schema": "",
+ "columns": {
+ "setting_key": {
+ "name": "setting_key",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "serverSetting_settingKey_unique": {
+ "name": "serverSetting_settingKey_unique",
+ "nullsNotDistinct": false,
+ "columns": ["setting_key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "varchar(512)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.trusted_certificate_hostname": {
+ "name": "trusted_certificate_hostname",
+ "schema": "",
+ "columns": {
+ "hostname": {
+ "name": "hostname",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thumbprint": {
+ "name": "thumbprint",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "certificate": {
+ "name": "certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "trusted_certificate_hostname_hostname_thumbprint_pk": {
+ "name": "trusted_certificate_hostname_hostname_thumbprint_pk",
+ "columns": ["hostname", "thumbprint"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'credentials'"
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_search_engine_id": {
+ "name": "default_search_engine_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "open_search_in_new_tab": {
+ "name": "open_search_in_new_tab",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "ddg_bangs": {
+ "name": "ddg_bangs",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "color_scheme": {
+ "name": "color_scheme",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'dark'"
+ },
+ "first_day_of_week": {
+ "name": "first_day_of_week",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "ping_icons_enabled": {
+ "name": "ping_icons_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "enable_right_click_on_widgets": {
+ "name": "enable_right_click_on_widgets",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "completed_manage_tour": {
+ "name": "completed_manage_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "completed_board_tour": {
+ "name": "completed_board_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_home_board_id_board_id_fk": {
+ "name": "user_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_mobile_home_board_id_board_id_fk": {
+ "name": "user_mobile_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_default_search_engine_id_search_engine_id_fk": {
+ "name": "user_default_search_engine_id_search_engine_id_fk",
+ "tableFrom": "user",
+ "tableTo": "search_engine",
+ "columnsFrom": ["default_search_engine_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verificationToken": {
+ "name": "verificationToken",
+ "schema": "",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "name": "verificationToken_identifier_token_pk",
+ "columns": ["identifier", "token"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.widget_secret": {
+ "name": "widget_secret",
+ "schema": "",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "widget_secret_item_id_item_id_fk": {
+ "name": "widget_secret_item_id_item_id_fk",
+ "tableFrom": "widget_secret",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "widget_secret_item_id_kind_pk": {
+ "name": "widget_secret_item_id_kind_pk",
+ "columns": ["item_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/db/migrations/postgresql/meta/0013_snapshot.json b/packages/db/migrations/postgresql/meta/0013_snapshot.json
new file mode 100644
index 000000000..215ff423a
--- /dev/null
+++ b/packages/db/migrations/postgresql/meta/0013_snapshot.json
@@ -0,0 +1,2670 @@
+{
+ "id": "ab2696e9-42d1-4aa0-bc60-b6d9fc50a7dc",
+ "prevId": "f1d7a91a-bf16-4f26-a0ae-ce77eab2a40e",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_provider_account_id_pk": {
+ "name": "account_provider_provider_account_id_pk",
+ "columns": ["provider", "provider_account_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.apiKey": {
+ "name": "apiKey",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apiKey_user_id_user_id_fk": {
+ "name": "apiKey_user_id_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.app": {
+ "name": "app",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ping_url": {
+ "name": "ping_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.assistant_configuration": {
+ "name": "assistant_configuration",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "web_search_enabled": {
+ "name": "web_search_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'openrouter'"
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'https://openrouter.ai/api/v1'"
+ },
+ "model_discovery_path": {
+ "name": "model_discovery_path",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'/models'"
+ },
+ "encrypted_api_key": {
+ "name": "encrypted_api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "encrypted_headers": {
+ "name": "encrypted_headers",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.assistant_message": {
+ "name": "assistant_message",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(128)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "format": {
+ "name": "format",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ai-sdk/v6'"
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "assistant_message__thread_id_created_at_idx": {
+ "name": "assistant_message__thread_id_created_at_idx",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "assistant_message_thread_id_assistant_thread_id_fk": {
+ "name": "assistant_message_thread_id_assistant_thread_id_fk",
+ "tableFrom": "assistant_message",
+ "tableTo": "assistant_thread",
+ "columnsFrom": ["thread_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.assistant_thread": {
+ "name": "assistant_thread",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "assistant_thread__user_id_updated_at_idx": {
+ "name": "assistant_thread__user_id_updated_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "assistant_thread_user_id_user_id_fk": {
+ "name": "assistant_thread_user_id_user_id_fk",
+ "tableFrom": "assistant_thread",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.boardGroupPermission": {
+ "name": "boardGroupPermission",
+ "schema": "",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardGroupPermission_board_id_board_id_fk": {
+ "name": "boardGroupPermission_board_id_board_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardGroupPermission_group_id_group_id_fk": {
+ "name": "boardGroupPermission_group_id_group_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardGroupPermission_board_id_group_id_permission_pk": {
+ "name": "boardGroupPermission_board_id_group_id_permission_pk",
+ "columns": ["board_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.boardUserPermission": {
+ "name": "boardUserPermission",
+ "schema": "",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardUserPermission_board_id_board_id_fk": {
+ "name": "boardUserPermission_board_id_board_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardUserPermission_user_id_user_id_fk": {
+ "name": "boardUserPermission_user_id_user_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardUserPermission_board_id_user_id_permission_pk": {
+ "name": "boardUserPermission_board_id_user_id_permission_pk",
+ "columns": ["board_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.board": {
+ "name": "board",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fixed'"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'no-repeat'"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'cover'"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#fa5252'"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#fd7e14'"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_color": {
+ "name": "icon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "item_radius": {
+ "name": "item_radius",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'lg'"
+ },
+ "disable_status": {
+ "name": "disable_status",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "board_creator_id_user_id_fk": {
+ "name": "board_creator_id_user_id_fk",
+ "tableFrom": "board",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cron_job_configuration": {
+ "name": "cron_job_configuration",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_v2_definition": {
+ "name": "custom_widget_v2_definition",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "requests": {
+ "name": "requests",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "template": {
+ "name": "template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_v2_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_v2_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_v2_secret": {
+ "name": "custom_widget_v2_secret",
+ "schema": "",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_secret_definition_id_fk": {
+ "name": "custom_widget_v2_secret_definition_id_fk",
+ "tableFrom": "custom_widget_v2_secret",
+ "tableTo": "custom_widget_v2_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_v2_secret_definition_id_source_id_kind_pk": {
+ "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk",
+ "columns": ["definition_id", "source_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.groupMember": {
+ "name": "groupMember",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupMember_group_id_group_id_fk": {
+ "name": "groupMember_group_id_group_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "groupMember_user_id_user_id_fk": {
+ "name": "groupMember_user_id_user_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "groupMember_group_id_user_id_pk": {
+ "name": "groupMember_group_id_user_id_pk",
+ "columns": ["group_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.groupPermission": {
+ "name": "groupPermission",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupPermission_group_id_group_id_fk": {
+ "name": "groupPermission_group_id_group_id_fk",
+ "tableFrom": "groupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.group": {
+ "name": "group",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "group_owner_id_user_id_fk": {
+ "name": "group_owner_id_user_id_fk",
+ "tableFrom": "group",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_home_board_id_board_id_fk": {
+ "name": "group_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_mobile_home_board_id_board_id_fk": {
+ "name": "group_mobile_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "group_name_unique": {
+ "name": "group_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.iconRepository": {
+ "name": "iconRepository",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.icon": {
+ "name": "icon",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(250)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon_repository_id": {
+ "name": "icon_repository_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "icon_icon_repository_id_iconRepository_id_fk": {
+ "name": "icon_icon_repository_id_iconRepository_id_fk",
+ "tableFrom": "icon",
+ "tableTo": "iconRepository",
+ "columnsFrom": ["icon_repository_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integrationGroupPermissions": {
+ "name": "integrationGroupPermissions",
+ "schema": "",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationGroupPermissions_integration_id_integration_id_fk": {
+ "name": "integrationGroupPermissions_integration_id_integration_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationGroupPermissions_group_id_group_id_fk": {
+ "name": "integrationGroupPermissions_group_id_group_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_group_permission__pk": {
+ "name": "integration_group_permission__pk",
+ "columns": ["integration_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integration_item": {
+ "name": "integration_item",
+ "schema": "",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "name": "integration_item_item_id_integration_id_pk",
+ "columns": ["item_id", "integration_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integrationSecret": {
+ "name": "integrationSecret",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "name": "integrationSecret_integration_id_kind_pk",
+ "columns": ["integration_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integrationUserPermission": {
+ "name": "integrationUserPermission",
+ "schema": "",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationUserPermission_integration_id_integration_id_fk": {
+ "name": "integrationUserPermission_integration_id_integration_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationUserPermission_user_id_user_id_fk": {
+ "name": "integrationUserPermission_user_id_user_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationUserPermission_integration_id_user_id_permission_pk": {
+ "name": "integrationUserPermission_integration_id_user_id_permission_pk",
+ "columns": ["integration_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integration": {
+ "name": "integration",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "integration_app_id_app_id_fk": {
+ "name": "integration_app_id_app_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "app",
+ "columnsFrom": ["app_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invite": {
+ "name": "invite",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expiration_date": {
+ "name": "expiration_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "invite_creator_id_user_id_fk": {
+ "name": "invite_creator_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.item_layout": {
+ "name": "item_layout",
+ "schema": "",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_layout_item_id_item_id_fk": {
+ "name": "item_layout_item_id_item_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_section_id_section_id_fk": {
+ "name": "item_layout_section_id_section_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_layout_id_layout_id_fk": {
+ "name": "item_layout_layout_id_layout_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_layout_item_id_section_id_layout_id_pk": {
+ "name": "item_layout_item_id_section_id_layout_id_pk",
+ "columns": ["item_id", "section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.item": {
+ "name": "item",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ },
+ "advanced_options": {
+ "name": "advanced_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_board_id_board_id_fk": {
+ "name": "item_board_id_board_id_fk",
+ "tableFrom": "item",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.layout": {
+ "name": "layout",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "left_gutter_column_count": {
+ "name": "left_gutter_column_count",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "right_gutter_column_count": {
+ "name": "right_gutter_column_count",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "breakpoint": {
+ "name": "breakpoint",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'custom'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "layout_board_id_board_id_fk": {
+ "name": "layout_board_id_board_id_fk",
+ "tableFrom": "layout",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_definition": {
+ "name": "custom_widget_definition",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "header_name": {
+ "name": "header_name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "method": {
+ "name": "method",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'GET'"
+ },
+ "request_body": {
+ "name": "request_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_type": {
+ "name": "display_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'singleValue'"
+ },
+ "display_config": {
+ "name": "display_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_secret": {
+ "name": "custom_widget_secret",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
+ "name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
+ "tableFrom": "custom_widget_secret",
+ "tableTo": "custom_widget_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_secret_definition_id_kind_pk": {
+ "name": "custom_widget_secret_definition_id_kind_pk",
+ "columns": ["definition_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media": {
+ "name": "media",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "bytea",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_creator_id_user_id_fk": {
+ "name": "media_creator_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.onboarding": {
+ "name": "onboarding",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "step": {
+ "name": "step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "previous_step": {
+ "name": "previous_step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.search_engine": {
+ "name": "search_engine",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "short": {
+ "name": "short",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url_template": {
+ "name": "url_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'generic'"
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "search_engine_integration_id_integration_id_fk": {
+ "name": "search_engine_integration_id_integration_id_fk",
+ "tableFrom": "search_engine",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "search_engine_short_unique": {
+ "name": "search_engine_short_unique",
+ "nullsNotDistinct": false,
+ "columns": ["short"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.section_collapse_state": {
+ "name": "section_collapse_state",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "collapsed": {
+ "name": "collapsed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_collapse_state_user_id_user_id_fk": {
+ "name": "section_collapse_state_user_id_user_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_collapse_state_section_id_section_id_fk": {
+ "name": "section_collapse_state_section_id_section_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_collapse_state_user_id_section_id_pk": {
+ "name": "section_collapse_state_user_id_section_id_pk",
+ "columns": ["user_id", "section_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.section_layout": {
+ "name": "section_layout",
+ "schema": "",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_section_id": {
+ "name": "parent_section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_layout_section_id_section_id_fk": {
+ "name": "section_layout_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_layout_id_layout_id_fk": {
+ "name": "section_layout_layout_id_layout_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_parent_section_id_section_id_fk": {
+ "name": "section_layout_parent_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["parent_section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_layout_section_id_layout_id_pk": {
+ "name": "section_layout_section_id_layout_id_pk",
+ "columns": ["section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.section": {
+ "name": "section",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.serverSetting": {
+ "name": "serverSetting",
+ "schema": "",
+ "columns": {
+ "setting_key": {
+ "name": "setting_key",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "serverSetting_settingKey_unique": {
+ "name": "serverSetting_settingKey_unique",
+ "nullsNotDistinct": false,
+ "columns": ["setting_key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "varchar(512)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.trusted_certificate_hostname": {
+ "name": "trusted_certificate_hostname",
+ "schema": "",
+ "columns": {
+ "hostname": {
+ "name": "hostname",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thumbprint": {
+ "name": "thumbprint",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "certificate": {
+ "name": "certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "trusted_certificate_hostname_hostname_thumbprint_pk": {
+ "name": "trusted_certificate_hostname_hostname_thumbprint_pk",
+ "columns": ["hostname", "thumbprint"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'credentials'"
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_search_engine_id": {
+ "name": "default_search_engine_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "open_search_in_new_tab": {
+ "name": "open_search_in_new_tab",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "ddg_bangs": {
+ "name": "ddg_bangs",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "color_scheme": {
+ "name": "color_scheme",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'dark'"
+ },
+ "first_day_of_week": {
+ "name": "first_day_of_week",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "ping_icons_enabled": {
+ "name": "ping_icons_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "enable_right_click_on_widgets": {
+ "name": "enable_right_click_on_widgets",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "completed_manage_tour": {
+ "name": "completed_manage_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "completed_board_tour": {
+ "name": "completed_board_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_home_board_id_board_id_fk": {
+ "name": "user_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_mobile_home_board_id_board_id_fk": {
+ "name": "user_mobile_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_default_search_engine_id_search_engine_id_fk": {
+ "name": "user_default_search_engine_id_search_engine_id_fk",
+ "tableFrom": "user",
+ "tableTo": "search_engine",
+ "columnsFrom": ["default_search_engine_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verificationToken": {
+ "name": "verificationToken",
+ "schema": "",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "name": "verificationToken_identifier_token_pk",
+ "columns": ["identifier", "token"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.widget_secret": {
+ "name": "widget_secret",
+ "schema": "",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "widget_secret_item_id_item_id_fk": {
+ "name": "widget_secret_item_id_item_id_fk",
+ "tableFrom": "widget_secret",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "widget_secret_item_id_kind_pk": {
+ "name": "widget_secret_item_id_kind_pk",
+ "columns": ["item_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/db/migrations/postgresql/meta/_journal.json b/packages/db/migrations/postgresql/meta/_journal.json
index c3dacd7f3..6006e43d3 100644
--- a/packages/db/migrations/postgresql/meta/_journal.json
+++ b/packages/db/migrations/postgresql/meta/_journal.json
@@ -85,6 +85,20 @@
"when": 1786212457549,
"tag": "0011_add_layout_gutters",
"breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "7",
+ "when": 1786215201393,
+ "tag": "0012_add_layout_role",
+ "breakpoints": true
+ },
+ {
+ "idx": 13,
+ "version": "7",
+ "when": 1786217088954,
+ "tag": "0013_add_assistant_tables",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/migrations/seed.spec.ts b/packages/db/migrations/seed.spec.ts
new file mode 100644
index 000000000..375849e29
--- /dev/null
+++ b/packages/db/migrations/seed.spec.ts
@@ -0,0 +1,97 @@
+import { describe, expect, test } from "vitest";
+
+import { createId } from "@homarr/common";
+import { eq } from "@homarr/db";
+import { boards, itemLayouts, items, layouts, sections } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+import { emptySuperJSON } from "@homarr/definitions";
+
+import { seedProtectedBoardLayoutsAsync } from "./seed";
+
+describe("protected board layout migration", () => {
+ test("turns a single saved layout into Base and generates Mobile", async () => {
+ const db = createDb();
+ const boardId = createId();
+ const baseLayoutId = createId();
+ const sectionId = createId();
+ const itemId = createId();
+ await db.insert(boards).values({ id: boardId, name: "single" });
+ await db.insert(layouts).values({
+ id: baseLayoutId,
+ boardId,
+ name: "Wide",
+ columnCount: 12,
+ breakpoint: 0,
+ });
+ await db.insert(sections).values({ id: sectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 });
+ await db.insert(items).values({ id: itemId, boardId, kind: "clock", options: emptySuperJSON });
+ await db.insert(itemLayouts).values({
+ itemId,
+ sectionId,
+ layoutId: baseLayoutId,
+ width: 5,
+ height: 2,
+ xOffset: 6,
+ yOffset: 1,
+ });
+
+ await seedProtectedBoardLayoutsAsync(db);
+ await seedProtectedBoardLayoutsAsync(db);
+
+ const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
+ expect(savedLayouts).toHaveLength(2);
+ expect(savedLayouts.find((layout) => layout.role === "base")).toMatchObject({
+ id: baseLayoutId,
+ name: "Wide",
+ columnCount: 12,
+ breakpoint: 768,
+ });
+ const mobileLayout = savedLayouts.find((layout) => layout.role === "mobile");
+ expect(mobileLayout).toMatchObject({ name: "Mobile", columnCount: 3, breakpoint: 0 });
+ const mobileItemPosition = await db.query.itemLayouts.findFirst({
+ where: eq(itemLayouts.layoutId, mobileLayout?.id ?? ""),
+ });
+ expect(mobileItemPosition).toMatchObject({ width: 3, height: 2, xOffset: 0, yOffset: 0 });
+ });
+
+ test("assigns roles and resolves duplicate breakpoints without rebuilding existing layouts", async () => {
+ const db = createDb();
+ const boardId = createId();
+ await db.insert(boards).values({ id: boardId, name: "responsive" });
+ await db.insert(layouts).values([
+ { id: "layout-mobile", boardId, name: "Phone", columnCount: 3, breakpoint: 0 },
+ { id: "layout-tablet-a", boardId, name: "Tablet A", columnCount: 6, breakpoint: 480 },
+ { id: "layout-tablet-b", boardId, name: "Tablet B", columnCount: 8, breakpoint: 480 },
+ { id: "layout-wide", boardId, name: "Wide", columnCount: 12, breakpoint: 1200 },
+ ]);
+
+ await seedProtectedBoardLayoutsAsync(db);
+ await seedProtectedBoardLayoutsAsync(db);
+
+ const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
+ expect(savedLayouts.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint)).toMatchObject([
+ { id: "layout-mobile", name: "Phone", columnCount: 3, breakpoint: 0, role: "mobile" },
+ { id: "layout-tablet-a", name: "Tablet A", columnCount: 6, breakpoint: 480, role: "custom" },
+ { id: "layout-tablet-b", name: "Tablet B", columnCount: 8, breakpoint: 481, role: "custom" },
+ { id: "layout-wide", name: "Wide", columnCount: 12, breakpoint: 1200, role: "base" },
+ ]);
+ });
+
+ test("creates both protected layouts for a board with no layouts", async () => {
+ const db = createDb();
+ const boardId = createId();
+ await db.insert(boards).values({ id: boardId, name: "empty" });
+
+ await seedProtectedBoardLayoutsAsync(db);
+ await seedProtectedBoardLayoutsAsync(db);
+
+ const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
+ expect(savedLayouts).toHaveLength(2);
+ expect(savedLayouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
+ expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
+ ]),
+ );
+ });
+});
diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts
index 412c09e1e..5248b8892 100644
--- a/packages/db/migrations/seed.ts
+++ b/packages/db/migrations/seed.ts
@@ -1,6 +1,6 @@
import SuperJSON from "superjson";
-import { createId, objectKeys } from "@homarr/common";
+import { createId, generateResponsiveGridFor, objectKeys } from "@homarr/common";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import {
createDocumentationLink,
@@ -11,11 +11,12 @@ import {
getIntegrationName,
integrationDefs,
integrationKinds,
+ normalizeBoardLayoutRoles,
} from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
-import type { Database } from "..";
+import type { Database, InferInsertModel } from "..";
import { eq, inArray } from "..";
import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
import {
@@ -39,6 +40,7 @@ import {
onboarding,
searchEngines,
sections,
+ sectionLayouts,
users,
} from "../schema";
import type { Integration } from "../schema";
@@ -65,6 +67,215 @@ export const seedDataAsync = async (db: Database) => {
if (isTruthyEnv(process.env.DEMO_MODE)) {
await seedDemoUserAsync(db);
}
+
+ await seedProtectedBoardLayoutsAsync(db);
+};
+
+export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: string) => {
+ const dbBoards = await db.query.boards.findMany({
+ where: boardId ? eq(boards.id, boardId) : undefined,
+ with: {
+ layouts: true,
+ items: { with: { layouts: true } },
+ sections: { with: { layouts: true } },
+ },
+ });
+
+ for (const board of dbBoards) {
+ if (board.layouts.length === 0) {
+ await db.insert(layouts).values([
+ {
+ id: createId(),
+ name: "Mobile",
+ columnCount: 3,
+ breakpoint: 0,
+ role: "mobile",
+ boardId: board.id,
+ },
+ {
+ id: createId(),
+ name: "Base",
+ columnCount: 10,
+ breakpoint: 768,
+ role: "base",
+ boardId: board.id,
+ },
+ ]);
+ continue;
+ }
+
+ if (board.layouts.length === 1) {
+ const [baseLayout] = board.layouts;
+ if (!baseLayout) continue;
+
+ const mobileLayout = {
+ id: createId(),
+ name: "Mobile",
+ columnCount: 3,
+ breakpoint: 0,
+ role: "mobile" as const,
+ boardId: board.id,
+ };
+
+ await db.update(layouts).set({ role: "base", breakpoint: 768 }).where(eq(layouts.id, baseLayout.id));
+ await db.insert(layouts).values(mobileLayout);
+ await insertMissingProjectedPositionsAsync(
+ db,
+ board,
+ { ...baseLayout, role: "base", breakpoint: 768 },
+ mobileLayout,
+ );
+ continue;
+ }
+
+ const normalizedLayouts = normalizeBoardLayoutRoles(board.layouts);
+ const alreadyNormalized = normalizedLayouts.every((layout) => {
+ const previousLayout = board.layouts.find((candidate) => candidate.id === layout.id);
+ return layout.breakpoint === previousLayout?.breakpoint && previousLayout.role === layout.role;
+ });
+
+ if (!alreadyNormalized) {
+ for (const layout of normalizedLayouts) {
+ await db
+ .update(layouts)
+ .set({ breakpoint: layout.breakpoint, role: layout.role })
+ .where(eq(layouts.id, layout.id));
+ }
+ }
+
+ const mobileLayout = normalizedLayouts[0];
+ const baseLayout = normalizedLayouts.at(-1);
+ if (mobileLayout && baseLayout) {
+ await insertMissingProjectedPositionsAsync(
+ db,
+ board,
+ { ...baseLayout, role: "base" },
+ { ...mobileLayout, role: "mobile" },
+ );
+ }
+ }
+};
+
+interface BoardWithLayoutPositions {
+ items: Array<{
+ id: string;
+ layouts: Array<{
+ layoutId: string;
+ sectionId: string;
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ }>;
+ }>;
+ sections: Array<{
+ id: string;
+ kind: string;
+ layouts: Array<{
+ layoutId: string;
+ parentSectionId: string | null;
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ }>;
+ }>;
+}
+
+const insertMissingProjectedPositionsAsync = async (
+ db: Database,
+ board: BoardWithLayoutPositions,
+ sourceLayout: InferInsertModel,
+ targetLayout: InferInsertModel,
+) => {
+ const elements = [
+ ...board.items.flatMap((item) => {
+ const layout = item.layouts.find((itemLayout) => itemLayout.layoutId === sourceLayout.id);
+ return layout
+ ? [
+ {
+ id: item.id,
+ type: "item" as const,
+ width: layout.width,
+ height: layout.height,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ sectionId: layout.sectionId,
+ },
+ ]
+ : [];
+ }),
+ ...board.sections.flatMap((section) => {
+ if (section.kind !== "dynamic") return [];
+ const layout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === sourceLayout.id);
+ return layout?.parentSectionId
+ ? [
+ {
+ id: section.id,
+ type: "section" as const,
+ width: layout.width,
+ height: layout.height,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ sectionId: layout.parentSectionId,
+ },
+ ]
+ : [];
+ }),
+ ];
+
+ const projectedElements = board.sections
+ .filter((section) => section.kind !== "dynamic")
+ .flatMap(
+ (section) =>
+ generateResponsiveGridFor({
+ items: elements,
+ previousWidth: sourceLayout.columnCount,
+ width: targetLayout.columnCount,
+ sectionId: section.id,
+ }).items,
+ );
+
+ const existingItemIds = new Set(
+ board.items
+ .filter((item) => item.layouts.some((layout) => layout.layoutId === targetLayout.id))
+ .map((item) => item.id),
+ );
+ const itemPositions = projectedElements
+ .filter((element) => element.type === "item" && !existingItemIds.has(element.id))
+ .map(
+ (element): InferInsertModel => ({
+ itemId: element.id,
+ layoutId: targetLayout.id,
+ sectionId: element.sectionId,
+ width: element.width,
+ height: element.height,
+ xOffset: element.xOffset,
+ yOffset: element.yOffset,
+ }),
+ );
+
+ const existingSectionIds = new Set(
+ board.sections
+ .filter((section) => section.layouts.some((layout) => layout.layoutId === targetLayout.id))
+ .map((section) => section.id),
+ );
+ const sectionPositions = projectedElements
+ .filter((element) => element.type === "section" && !existingSectionIds.has(element.id))
+ .map(
+ (element): InferInsertModel => ({
+ sectionId: element.id,
+ layoutId: targetLayout.id,
+ parentSectionId: element.sectionId,
+ width: element.width,
+ height: element.height,
+ xOffset: element.xOffset,
+ yOffset: element.yOffset,
+ }),
+ );
+
+ if (itemPositions.length > 0) await db.insert(itemLayouts).values(itemPositions);
+ if (sectionPositions.length > 0) await db.insert(sectionLayouts).values(sectionPositions);
};
const seedEveryoneGroupAsync = async (db: Database) => {
@@ -271,13 +482,24 @@ const seedDefaultBoardAsync = async (db: Database) => {
yOffset: 0,
boardId,
});
- await db.insert(layouts).values({
- id: createId(),
- name: "Base",
- columnCount: 10,
- breakpoint: 0,
- boardId,
- });
+ await db.insert(layouts).values([
+ {
+ id: createId(),
+ name: "Mobile",
+ columnCount: 3,
+ breakpoint: 0,
+ role: "mobile",
+ boardId,
+ },
+ {
+ id: createId(),
+ name: "Base",
+ columnCount: 10,
+ breakpoint: 768,
+ role: "base",
+ boardId,
+ },
+ ]);
const everyoneGroupRow = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
@@ -422,6 +644,8 @@ const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
+ // Row 12: assistant
+ { kind: "assistant", width: 3, height: 3, needsIntegration: false },
];
const seedDemoUserAsync = async (db: Database) => {
@@ -508,7 +732,8 @@ const seedDemoUserAsync = async (db: Database) => {
id: layoutId,
name: "Base",
columnCount: 12,
- breakpoint: 0,
+ breakpoint: 768,
+ role: "base",
boardId,
});
@@ -598,7 +823,7 @@ const seedBoardWidgetsAsync = async (db: Database) => {
if (!board) return;
const section = board.sections.find((sec) => sec.kind === "empty");
- const layout = board.layouts[0];
+ const layout = board.layouts.find((candidate) => candidate.role === "base") ?? board.layouts[0];
if (!section || !layout) return;
const allIntegrations = await db.query.integrations.findMany();
diff --git a/packages/db/migrations/sqlite/0044_add_layout_role.sql b/packages/db/migrations/sqlite/0044_add_layout_role.sql
new file mode 100644
index 000000000..3c9fd79c9
--- /dev/null
+++ b/packages/db/migrations/sqlite/0044_add_layout_role.sql
@@ -0,0 +1 @@
+ALTER TABLE `layout` ADD `role` text DEFAULT 'custom' NOT NULL;
\ No newline at end of file
diff --git a/packages/db/migrations/sqlite/0045_add_assistant_tables.sql b/packages/db/migrations/sqlite/0045_add_assistant_tables.sql
new file mode 100644
index 000000000..c9e04c96d
--- /dev/null
+++ b/packages/db/migrations/sqlite/0045_add_assistant_tables.sql
@@ -0,0 +1,35 @@
+CREATE TABLE `assistant_configuration` (
+ `id` text PRIMARY KEY DEFAULT 'default' NOT NULL,
+ `enabled` integer DEFAULT false NOT NULL,
+ `web_search_enabled` integer DEFAULT false NOT NULL,
+ `provider` text DEFAULT 'openrouter' NOT NULL,
+ `base_url` text DEFAULT 'https://openrouter.ai/api/v1' NOT NULL,
+ `model_discovery_path` text DEFAULT '/models',
+ `encrypted_api_key` text,
+ `encrypted_headers` text,
+ `model_id` text,
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `assistant_message` (
+ `id` text PRIMARY KEY NOT NULL,
+ `thread_id` text NOT NULL,
+ `parent_id` text,
+ `format` text DEFAULT 'ai-sdk/v6' NOT NULL,
+ `content` text NOT NULL,
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
+ FOREIGN KEY (`thread_id`) REFERENCES `assistant_thread`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `assistant_message__thread_id_created_at_idx` ON `assistant_message` (`thread_id`,`created_at`);--> statement-breakpoint
+CREATE TABLE `assistant_thread` (
+ `id` text PRIMARY KEY NOT NULL,
+ `user_id` text NOT NULL,
+ `title` text,
+ `model_id` text,
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL,
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `assistant_thread__user_id_updated_at_idx` ON `assistant_thread` (`user_id`,`updated_at`);
\ No newline at end of file
diff --git a/packages/db/migrations/sqlite/meta/0044_snapshot.json b/packages/db/migrations/sqlite/meta/0044_snapshot.json
new file mode 100644
index 000000000..c916baf01
--- /dev/null
+++ b/packages/db/migrations/sqlite/meta/0044_snapshot.json
@@ -0,0 +1,2472 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "ba1d8f76-d167-405c-97a6-ade428e51df1",
+ "prevId": "e868162b-3757-4d9e-940c-facc749256d6",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_provider_account_id_pk": {
+ "columns": ["provider", "provider_account_id"],
+ "name": "account_provider_provider_account_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apiKey_user_id_user_id_fk": {
+ "name": "apiKey_user_id_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "app": {
+ "name": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ping_url": {
+ "name": "ping_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "boardGroupPermission": {
+ "name": "boardGroupPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardGroupPermission_board_id_board_id_fk": {
+ "name": "boardGroupPermission_board_id_board_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardGroupPermission_group_id_group_id_fk": {
+ "name": "boardGroupPermission_group_id_group_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardGroupPermission_board_id_group_id_permission_pk": {
+ "columns": ["board_id", "group_id", "permission"],
+ "name": "boardGroupPermission_board_id_group_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "boardUserPermission": {
+ "name": "boardUserPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardUserPermission_board_id_board_id_fk": {
+ "name": "boardUserPermission_board_id_board_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardUserPermission_user_id_user_id_fk": {
+ "name": "boardUserPermission_user_id_user_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardUserPermission_board_id_user_id_permission_pk": {
+ "columns": ["board_id", "user_id", "permission"],
+ "name": "boardUserPermission_board_id_user_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "board": {
+ "name": "board",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'no-repeat'"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'cover'"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#fa5252'"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#fd7e14'"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_color": {
+ "name": "icon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_radius": {
+ "name": "item_radius",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'lg'"
+ },
+ "disable_status": {
+ "name": "disable_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "columns": ["name"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "board_creator_id_user_id_fk": {
+ "name": "board_creator_id_user_id_fk",
+ "tableFrom": "board",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cron_job_configuration": {
+ "name": "cron_job_configuration",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_v2_definition": {
+ "name": "custom_widget_v2_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requests": {
+ "name": "requests",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "template": {
+ "name": "template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_v2_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_v2_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_v2_secret": {
+ "name": "custom_widget_v2_secret",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_secret_definition_id_fk": {
+ "name": "custom_widget_v2_secret_definition_id_fk",
+ "tableFrom": "custom_widget_v2_secret",
+ "tableTo": "custom_widget_v2_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_v2_secret_definition_id_source_id_kind_pk": {
+ "columns": ["definition_id", "source_id", "kind"],
+ "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "groupMember": {
+ "name": "groupMember",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupMember_group_id_group_id_fk": {
+ "name": "groupMember_group_id_group_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "groupMember_user_id_user_id_fk": {
+ "name": "groupMember_user_id_user_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "groupMember_group_id_user_id_pk": {
+ "columns": ["group_id", "user_id"],
+ "name": "groupMember_group_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "groupPermission": {
+ "name": "groupPermission",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupPermission_group_id_group_id_fk": {
+ "name": "groupPermission_group_id_group_id_fk",
+ "tableFrom": "groupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "group": {
+ "name": "group",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "group_name_unique": {
+ "name": "group_name_unique",
+ "columns": ["name"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "group_owner_id_user_id_fk": {
+ "name": "group_owner_id_user_id_fk",
+ "tableFrom": "group",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_home_board_id_board_id_fk": {
+ "name": "group_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_mobile_home_board_id_board_id_fk": {
+ "name": "group_mobile_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "iconRepository": {
+ "name": "iconRepository",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "icon": {
+ "name": "icon",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_repository_id": {
+ "name": "icon_repository_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "icon_icon_repository_id_iconRepository_id_fk": {
+ "name": "icon_icon_repository_id_iconRepository_id_fk",
+ "tableFrom": "icon",
+ "tableTo": "iconRepository",
+ "columnsFrom": ["icon_repository_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integrationGroupPermissions": {
+ "name": "integrationGroupPermissions",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationGroupPermissions_integration_id_integration_id_fk": {
+ "name": "integrationGroupPermissions_integration_id_integration_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationGroupPermissions_group_id_group_id_fk": {
+ "name": "integrationGroupPermissions_group_id_group_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationGroupPermissions_integration_id_group_id_permission_pk": {
+ "columns": ["integration_id", "group_id", "permission"],
+ "name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_item": {
+ "name": "integration_item",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "columns": ["item_id", "integration_id"],
+ "name": "integration_item_item_id_integration_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integrationSecret": {
+ "name": "integrationSecret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": ["updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "columns": ["integration_id", "kind"],
+ "name": "integrationSecret_integration_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integrationUserPermission": {
+ "name": "integrationUserPermission",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationUserPermission_integration_id_integration_id_fk": {
+ "name": "integrationUserPermission_integration_id_integration_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationUserPermission_user_id_user_id_fk": {
+ "name": "integrationUserPermission_user_id_user_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationUserPermission_integration_id_user_id_permission_pk": {
+ "columns": ["integration_id", "user_id", "permission"],
+ "name": "integrationUserPermission_integration_id_user_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration": {
+ "name": "integration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integration_app_id_app_id_fk": {
+ "name": "integration_app_id_app_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "app",
+ "columnsFrom": ["app_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invite": {
+ "name": "invite",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiration_date": {
+ "name": "expiration_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "columns": ["token"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invite_creator_id_user_id_fk": {
+ "name": "invite_creator_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "item_layout": {
+ "name": "item_layout",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_layout_item_id_item_id_fk": {
+ "name": "item_layout_item_id_item_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_section_id_section_id_fk": {
+ "name": "item_layout_section_id_section_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_layout_id_layout_id_fk": {
+ "name": "item_layout_layout_id_layout_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_layout_item_id_section_id_layout_id_pk": {
+ "columns": ["item_id", "section_id", "layout_id"],
+ "name": "item_layout_item_id_section_id_layout_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "item": {
+ "name": "item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ },
+ "advanced_options": {
+ "name": "advanced_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_board_id_board_id_fk": {
+ "name": "item_board_id_board_id_fk",
+ "tableFrom": "item",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "layout": {
+ "name": "layout",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "left_gutter_column_count": {
+ "name": "left_gutter_column_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "right_gutter_column_count": {
+ "name": "right_gutter_column_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "breakpoint": {
+ "name": "breakpoint",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'custom'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "layout_board_id_board_id_fk": {
+ "name": "layout_board_id_board_id_fk",
+ "tableFrom": "layout",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_definition": {
+ "name": "custom_widget_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "header_name": {
+ "name": "header_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'GET'"
+ },
+ "request_body": {
+ "name": "request_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "display_type": {
+ "name": "display_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'singleValue'"
+ },
+ "display_config": {
+ "name": "display_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_secret": {
+ "name": "custom_widget_secret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
+ "name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
+ "tableFrom": "custom_widget_secret",
+ "tableTo": "custom_widget_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_secret_definition_id_kind_pk": {
+ "columns": ["definition_id", "kind"],
+ "name": "custom_widget_secret_definition_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "media": {
+ "name": "media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_creator_id_user_id_fk": {
+ "name": "media_creator_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "onboarding": {
+ "name": "onboarding",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "step": {
+ "name": "step",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "previous_step": {
+ "name": "previous_step",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "search_engine": {
+ "name": "search_engine",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "short": {
+ "name": "short",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url_template": {
+ "name": "url_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'generic'"
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "search_engine_short_unique": {
+ "name": "search_engine_short_unique",
+ "columns": ["short"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "search_engine_integration_id_integration_id_fk": {
+ "name": "search_engine_integration_id_integration_id_fk",
+ "tableFrom": "search_engine",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "section_collapse_state": {
+ "name": "section_collapse_state",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collapsed": {
+ "name": "collapsed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_collapse_state_user_id_user_id_fk": {
+ "name": "section_collapse_state_user_id_user_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_collapse_state_section_id_section_id_fk": {
+ "name": "section_collapse_state_section_id_section_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_collapse_state_user_id_section_id_pk": {
+ "columns": ["user_id", "section_id"],
+ "name": "section_collapse_state_user_id_section_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "section_layout": {
+ "name": "section_layout",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_section_id": {
+ "name": "parent_section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_layout_section_id_section_id_fk": {
+ "name": "section_layout_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_layout_id_layout_id_fk": {
+ "name": "section_layout_layout_id_layout_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_parent_section_id_section_id_fk": {
+ "name": "section_layout_parent_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["parent_section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_layout_section_id_layout_id_pk": {
+ "columns": ["section_id", "layout_id"],
+ "name": "section_layout_section_id_layout_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "section": {
+ "name": "section",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "serverSetting": {
+ "name": "serverSetting",
+ "columns": {
+ "setting_key": {
+ "name": "setting_key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {
+ "serverSetting_settingKey_unique": {
+ "name": "serverSetting_settingKey_unique",
+ "columns": ["setting_key"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "trusted_certificate_hostname": {
+ "name": "trusted_certificate_hostname",
+ "columns": {
+ "hostname": {
+ "name": "hostname",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thumbprint": {
+ "name": "thumbprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "certificate": {
+ "name": "certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "trusted_certificate_hostname_hostname_thumbprint_pk": {
+ "columns": ["hostname", "thumbprint"],
+ "name": "trusted_certificate_hostname_hostname_thumbprint_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'credentials'"
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_search_engine_id": {
+ "name": "default_search_engine_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "open_search_in_new_tab": {
+ "name": "open_search_in_new_tab",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "ddg_bangs": {
+ "name": "ddg_bangs",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "color_scheme": {
+ "name": "color_scheme",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'dark'"
+ },
+ "first_day_of_week": {
+ "name": "first_day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "ping_icons_enabled": {
+ "name": "ping_icons_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "enable_right_click_on_widgets": {
+ "name": "enable_right_click_on_widgets",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "completed_manage_tour": {
+ "name": "completed_manage_tour",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "completed_board_tour": {
+ "name": "completed_board_tour",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_home_board_id_board_id_fk": {
+ "name": "user_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_mobile_home_board_id_board_id_fk": {
+ "name": "user_mobile_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_default_search_engine_id_search_engine_id_fk": {
+ "name": "user_default_search_engine_id_search_engine_id_fk",
+ "tableFrom": "user",
+ "tableTo": "search_engine",
+ "columnsFrom": ["default_search_engine_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": ["identifier", "token"],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "widget_secret": {
+ "name": "widget_secret",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "widget_secret_item_id_item_id_fk": {
+ "name": "widget_secret_item_id_item_id_fk",
+ "tableFrom": "widget_secret",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "widget_secret_item_id_kind_pk": {
+ "columns": ["item_id", "kind"],
+ "name": "widget_secret_item_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/packages/db/migrations/sqlite/meta/0045_snapshot.json b/packages/db/migrations/sqlite/meta/0045_snapshot.json
new file mode 100644
index 000000000..fb6696542
--- /dev/null
+++ b/packages/db/migrations/sqlite/meta/0045_snapshot.json
@@ -0,0 +1,2699 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "5114a19e-3b78-482a-a68b-69874a05ee76",
+ "prevId": "ba1d8f76-d167-405c-97a6-ade428e51df1",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_provider_account_id_pk": {
+ "columns": ["provider", "provider_account_id"],
+ "name": "account_provider_provider_account_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apiKey_user_id_user_id_fk": {
+ "name": "apiKey_user_id_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "app": {
+ "name": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ping_url": {
+ "name": "ping_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "assistant_configuration": {
+ "name": "assistant_configuration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'default'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "web_search_enabled": {
+ "name": "web_search_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'openrouter'"
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'https://openrouter.ai/api/v1'"
+ },
+ "model_discovery_path": {
+ "name": "model_discovery_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'/models'"
+ },
+ "encrypted_api_key": {
+ "name": "encrypted_api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "encrypted_headers": {
+ "name": "encrypted_headers",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "assistant_message": {
+ "name": "assistant_message",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'ai-sdk/v6'"
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "assistant_message__thread_id_created_at_idx": {
+ "name": "assistant_message__thread_id_created_at_idx",
+ "columns": ["thread_id", "created_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assistant_message_thread_id_assistant_thread_id_fk": {
+ "name": "assistant_message_thread_id_assistant_thread_id_fk",
+ "tableFrom": "assistant_message",
+ "tableTo": "assistant_thread",
+ "columnsFrom": ["thread_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "assistant_thread": {
+ "name": "assistant_thread",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model_id": {
+ "name": "model_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ }
+ },
+ "indexes": {
+ "assistant_thread__user_id_updated_at_idx": {
+ "name": "assistant_thread__user_id_updated_at_idx",
+ "columns": ["user_id", "updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assistant_thread_user_id_user_id_fk": {
+ "name": "assistant_thread_user_id_user_id_fk",
+ "tableFrom": "assistant_thread",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "boardGroupPermission": {
+ "name": "boardGroupPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardGroupPermission_board_id_board_id_fk": {
+ "name": "boardGroupPermission_board_id_board_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardGroupPermission_group_id_group_id_fk": {
+ "name": "boardGroupPermission_group_id_group_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardGroupPermission_board_id_group_id_permission_pk": {
+ "columns": ["board_id", "group_id", "permission"],
+ "name": "boardGroupPermission_board_id_group_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "boardUserPermission": {
+ "name": "boardUserPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardUserPermission_board_id_board_id_fk": {
+ "name": "boardUserPermission_board_id_board_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardUserPermission_user_id_user_id_fk": {
+ "name": "boardUserPermission_user_id_user_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardUserPermission_board_id_user_id_permission_pk": {
+ "columns": ["board_id", "user_id", "permission"],
+ "name": "boardUserPermission_board_id_user_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "board": {
+ "name": "board",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'no-repeat'"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'cover'"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#fa5252'"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#fd7e14'"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_color": {
+ "name": "icon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_radius": {
+ "name": "item_radius",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'lg'"
+ },
+ "disable_status": {
+ "name": "disable_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "columns": ["name"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "board_creator_id_user_id_fk": {
+ "name": "board_creator_id_user_id_fk",
+ "tableFrom": "board",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cron_job_configuration": {
+ "name": "cron_job_configuration",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_v2_definition": {
+ "name": "custom_widget_v2_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requests": {
+ "name": "requests",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "template": {
+ "name": "template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_v2_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_v2_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_v2_secret": {
+ "name": "custom_widget_v2_secret",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_secret_definition_id_fk": {
+ "name": "custom_widget_v2_secret_definition_id_fk",
+ "tableFrom": "custom_widget_v2_secret",
+ "tableTo": "custom_widget_v2_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_v2_secret_definition_id_source_id_kind_pk": {
+ "columns": ["definition_id", "source_id", "kind"],
+ "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "groupMember": {
+ "name": "groupMember",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupMember_group_id_group_id_fk": {
+ "name": "groupMember_group_id_group_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "groupMember_user_id_user_id_fk": {
+ "name": "groupMember_user_id_user_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "groupMember_group_id_user_id_pk": {
+ "columns": ["group_id", "user_id"],
+ "name": "groupMember_group_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "groupPermission": {
+ "name": "groupPermission",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupPermission_group_id_group_id_fk": {
+ "name": "groupPermission_group_id_group_id_fk",
+ "tableFrom": "groupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "group": {
+ "name": "group",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "group_name_unique": {
+ "name": "group_name_unique",
+ "columns": ["name"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "group_owner_id_user_id_fk": {
+ "name": "group_owner_id_user_id_fk",
+ "tableFrom": "group",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_home_board_id_board_id_fk": {
+ "name": "group_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_mobile_home_board_id_board_id_fk": {
+ "name": "group_mobile_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "iconRepository": {
+ "name": "iconRepository",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "icon": {
+ "name": "icon",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_repository_id": {
+ "name": "icon_repository_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "icon_icon_repository_id_iconRepository_id_fk": {
+ "name": "icon_icon_repository_id_iconRepository_id_fk",
+ "tableFrom": "icon",
+ "tableTo": "iconRepository",
+ "columnsFrom": ["icon_repository_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integrationGroupPermissions": {
+ "name": "integrationGroupPermissions",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationGroupPermissions_integration_id_integration_id_fk": {
+ "name": "integrationGroupPermissions_integration_id_integration_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationGroupPermissions_group_id_group_id_fk": {
+ "name": "integrationGroupPermissions_group_id_group_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationGroupPermissions_integration_id_group_id_permission_pk": {
+ "columns": ["integration_id", "group_id", "permission"],
+ "name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_item": {
+ "name": "integration_item",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "columns": ["item_id", "integration_id"],
+ "name": "integration_item_item_id_integration_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integrationSecret": {
+ "name": "integrationSecret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": ["updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "columns": ["integration_id", "kind"],
+ "name": "integrationSecret_integration_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integrationUserPermission": {
+ "name": "integrationUserPermission",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationUserPermission_integration_id_integration_id_fk": {
+ "name": "integrationUserPermission_integration_id_integration_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationUserPermission_user_id_user_id_fk": {
+ "name": "integrationUserPermission_user_id_user_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationUserPermission_integration_id_user_id_permission_pk": {
+ "columns": ["integration_id", "user_id", "permission"],
+ "name": "integrationUserPermission_integration_id_user_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration": {
+ "name": "integration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integration_app_id_app_id_fk": {
+ "name": "integration_app_id_app_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "app",
+ "columnsFrom": ["app_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invite": {
+ "name": "invite",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiration_date": {
+ "name": "expiration_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "columns": ["token"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invite_creator_id_user_id_fk": {
+ "name": "invite_creator_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "item_layout": {
+ "name": "item_layout",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_layout_item_id_item_id_fk": {
+ "name": "item_layout_item_id_item_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_section_id_section_id_fk": {
+ "name": "item_layout_section_id_section_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_layout_id_layout_id_fk": {
+ "name": "item_layout_layout_id_layout_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_layout_item_id_section_id_layout_id_pk": {
+ "columns": ["item_id", "section_id", "layout_id"],
+ "name": "item_layout_item_id_section_id_layout_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "item": {
+ "name": "item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ },
+ "advanced_options": {
+ "name": "advanced_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_board_id_board_id_fk": {
+ "name": "item_board_id_board_id_fk",
+ "tableFrom": "item",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "layout": {
+ "name": "layout",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "left_gutter_column_count": {
+ "name": "left_gutter_column_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "right_gutter_column_count": {
+ "name": "right_gutter_column_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "breakpoint": {
+ "name": "breakpoint",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'custom'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "layout_board_id_board_id_fk": {
+ "name": "layout_board_id_board_id_fk",
+ "tableFrom": "layout",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_definition": {
+ "name": "custom_widget_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "header_name": {
+ "name": "header_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'GET'"
+ },
+ "request_body": {
+ "name": "request_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "display_type": {
+ "name": "display_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'singleValue'"
+ },
+ "display_config": {
+ "name": "display_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_secret": {
+ "name": "custom_widget_secret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
+ "name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
+ "tableFrom": "custom_widget_secret",
+ "tableTo": "custom_widget_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_secret_definition_id_kind_pk": {
+ "columns": ["definition_id", "kind"],
+ "name": "custom_widget_secret_definition_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "media": {
+ "name": "media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_creator_id_user_id_fk": {
+ "name": "media_creator_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "onboarding": {
+ "name": "onboarding",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "step": {
+ "name": "step",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "previous_step": {
+ "name": "previous_step",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "search_engine": {
+ "name": "search_engine",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "short": {
+ "name": "short",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url_template": {
+ "name": "url_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'generic'"
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "search_engine_short_unique": {
+ "name": "search_engine_short_unique",
+ "columns": ["short"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "search_engine_integration_id_integration_id_fk": {
+ "name": "search_engine_integration_id_integration_id_fk",
+ "tableFrom": "search_engine",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "section_collapse_state": {
+ "name": "section_collapse_state",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collapsed": {
+ "name": "collapsed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_collapse_state_user_id_user_id_fk": {
+ "name": "section_collapse_state_user_id_user_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_collapse_state_section_id_section_id_fk": {
+ "name": "section_collapse_state_section_id_section_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_collapse_state_user_id_section_id_pk": {
+ "columns": ["user_id", "section_id"],
+ "name": "section_collapse_state_user_id_section_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "section_layout": {
+ "name": "section_layout",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_section_id": {
+ "name": "parent_section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_layout_section_id_section_id_fk": {
+ "name": "section_layout_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_layout_id_layout_id_fk": {
+ "name": "section_layout_layout_id_layout_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_parent_section_id_section_id_fk": {
+ "name": "section_layout_parent_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["parent_section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_layout_section_id_layout_id_pk": {
+ "columns": ["section_id", "layout_id"],
+ "name": "section_layout_section_id_layout_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "section": {
+ "name": "section",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "serverSetting": {
+ "name": "serverSetting",
+ "columns": {
+ "setting_key": {
+ "name": "setting_key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {
+ "serverSetting_settingKey_unique": {
+ "name": "serverSetting_settingKey_unique",
+ "columns": ["setting_key"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "trusted_certificate_hostname": {
+ "name": "trusted_certificate_hostname",
+ "columns": {
+ "hostname": {
+ "name": "hostname",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thumbprint": {
+ "name": "thumbprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "certificate": {
+ "name": "certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "trusted_certificate_hostname_hostname_thumbprint_pk": {
+ "columns": ["hostname", "thumbprint"],
+ "name": "trusted_certificate_hostname_hostname_thumbprint_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'credentials'"
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_search_engine_id": {
+ "name": "default_search_engine_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "open_search_in_new_tab": {
+ "name": "open_search_in_new_tab",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "ddg_bangs": {
+ "name": "ddg_bangs",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "color_scheme": {
+ "name": "color_scheme",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'dark'"
+ },
+ "first_day_of_week": {
+ "name": "first_day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "ping_icons_enabled": {
+ "name": "ping_icons_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "enable_right_click_on_widgets": {
+ "name": "enable_right_click_on_widgets",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "completed_manage_tour": {
+ "name": "completed_manage_tour",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "completed_board_tour": {
+ "name": "completed_board_tour",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_home_board_id_board_id_fk": {
+ "name": "user_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_mobile_home_board_id_board_id_fk": {
+ "name": "user_mobile_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_default_search_engine_id_search_engine_id_fk": {
+ "name": "user_default_search_engine_id_search_engine_id_fk",
+ "tableFrom": "user",
+ "tableTo": "search_engine",
+ "columnsFrom": ["default_search_engine_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": ["identifier", "token"],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "widget_secret": {
+ "name": "widget_secret",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "widget_secret_item_id_item_id_fk": {
+ "name": "widget_secret_item_id_item_id_fk",
+ "tableFrom": "widget_secret",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "widget_secret_item_id_kind_pk": {
+ "columns": ["item_id", "kind"],
+ "name": "widget_secret_item_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json
index ed48c0b57..da059e51c 100644
--- a/packages/db/migrations/sqlite/meta/_journal.json
+++ b/packages/db/migrations/sqlite/meta/_journal.json
@@ -309,6 +309,20 @@
"when": 1786212459480,
"tag": "0043_add_layout_gutters",
"breakpoints": true
+ },
+ {
+ "idx": 44,
+ "version": "6",
+ "when": 1786215203092,
+ "tag": "0044_add_layout_role",
+ "breakpoints": true
+ },
+ {
+ "idx": 45,
+ "version": "6",
+ "when": 1786217088950,
+ "tag": "0045_add_assistant_tables",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/package.json b/packages/db/package.json
index 3c269b572..01d0d8810 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -9,6 +9,7 @@
"exports": {
".": "./index.ts",
"./collection": "./collection.ts",
+ "./migrations/seed": "./migrations/seed.ts",
"./proxy-reader": "./proxy-reader.ts",
"./schema": "./schema/index.ts",
"./test": "./test/index.ts",
diff --git a/packages/db/queries/widget-placement.spec.ts b/packages/db/queries/widget-placement.spec.ts
new file mode 100644
index 000000000..b8acdc3b3
--- /dev/null
+++ b/packages/db/queries/widget-placement.spec.ts
@@ -0,0 +1,57 @@
+import { describe, expect, test } from "vitest";
+
+import { createId } from "@homarr/common";
+import { boards, layouts, sections } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { placeAllWidgetsAsync } from "./widget-placement";
+
+describe("placeAllWidgetsAsync", () => {
+ test("creates independent positions for every target layout", async () => {
+ const db = createDb();
+ const boardId = createId();
+ const sectionId = createId();
+ const mobileLayoutId = createId();
+ const baseLayoutId = createId();
+ await db.insert(boards).values({ id: boardId, name: "dashboard" });
+ await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId });
+ await db.insert(layouts).values([
+ {
+ id: mobileLayoutId,
+ name: "Mobile",
+ role: "mobile",
+ columnCount: 3,
+ breakpoint: 0,
+ boardId,
+ },
+ {
+ id: baseLayoutId,
+ name: "Base",
+ role: "base",
+ columnCount: 10,
+ breakpoint: 768,
+ boardId,
+ },
+ ]);
+
+ await placeAllWidgetsAsync(
+ db,
+ [
+ { boardId, sectionId, layoutId: mobileLayoutId, columnCount: 3 },
+ { boardId, sectionId, layoutId: baseLayoutId, columnCount: 10 },
+ ],
+ [],
+ [{ id: createId(), name: "Homarr" }],
+ );
+
+ const placedItems = await db.query.items.findMany({ with: { layouts: true } });
+ expect(placedItems.length).toBeGreaterThan(0);
+ expect(placedItems.every((item) => item.layouts.length === 2)).toBe(true);
+ expect(
+ placedItems.every((item) =>
+ item.layouts.some((layout) => layout.layoutId === mobileLayoutId && layout.width <= 3),
+ ),
+ ).toBe(true);
+ expect(placedItems.every((item) => item.layouts.some((layout) => layout.layoutId === baseLayoutId))).toBe(true);
+ });
+});
diff --git a/packages/db/queries/widget-placement.ts b/packages/db/queries/widget-placement.ts
index 1d0b529f9..c3a246b2f 100644
--- a/packages/db/queries/widget-placement.ts
+++ b/packages/db/queries/widget-placement.ts
@@ -30,46 +30,54 @@ interface PlacementContext extends BoardTarget {
const placeWidgetAsync = async (
db: Database,
- ctx: PlacementContext,
+ contexts: PlacementContext[],
kind: WidgetKind,
linkedIntegrationIds: string[],
options?: string,
size?: { width: number; height: number },
) => {
- const itemWidth = size?.width ?? 2;
- const itemHeight = size?.height ?? 2;
+ const boardId = contexts.at(0)?.boardId;
+ if (!boardId) return;
- if (ctx.xOffset + itemWidth > ctx.columnCount) {
- ctx.xOffset = 0;
- ctx.yOffset += ctx.rowMaxHeight;
- ctx.rowMaxHeight = 0;
- }
+ const desiredItemWidth = size?.width ?? 2;
+ const itemHeight = size?.height ?? 2;
const itemId = createId();
await db.insert(items).values({
id: itemId,
- boardId: ctx.boardId,
+ boardId,
kind,
options: options ?? emptySuperJSON,
advancedOptions: emptySuperJSON,
});
- await db.insert(itemLayouts).values({
- itemId,
- sectionId: ctx.sectionId,
- layoutId: ctx.layoutId,
- xOffset: ctx.xOffset,
- yOffset: ctx.yOffset,
- width: itemWidth,
- height: itemHeight,
- });
+ await db.insert(itemLayouts).values(
+ contexts.map((ctx) => {
+ const itemWidth = Math.min(ctx.columnCount, desiredItemWidth);
+ if (ctx.xOffset + itemWidth > ctx.columnCount) {
+ ctx.xOffset = 0;
+ ctx.yOffset += ctx.rowMaxHeight;
+ ctx.rowMaxHeight = 0;
+ }
+
+ const placement = {
+ itemId,
+ sectionId: ctx.sectionId,
+ layoutId: ctx.layoutId,
+ xOffset: ctx.xOffset,
+ yOffset: ctx.yOffset,
+ width: itemWidth,
+ height: itemHeight,
+ };
+ ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
+ ctx.xOffset += itemWidth;
+ return placement;
+ }),
+ );
for (const integrationId of linkedIntegrationIds) {
await db.insert(integrationItems).values({ itemId, integrationId });
}
-
- ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
- ctx.xOffset += itemWidth;
};
interface App {
@@ -79,16 +87,17 @@ interface App {
export const placeAllWidgetsAsync = async (
db: Database,
- target: BoardTarget,
+ target: BoardTarget | BoardTarget[],
allIntegrations: Integration[],
allApps: App[],
) => {
- const ctx: PlacementContext = {
- ...target,
+ const targets = Array.isArray(target) ? target : [target];
+ const contexts: PlacementContext[] = targets.map((currentTarget) => ({
+ ...currentTarget,
xOffset: 0,
yOffset: 0,
rowMaxHeight: 0,
- };
+ }));
const placedWidgets = new Set();
@@ -106,7 +115,7 @@ export const placeAllWidgetsAsync = async (
const options = config?.options ? superjson.stringify(config.options) : undefined;
await placeWidgetAsync(
db,
- ctx,
+ contexts,
widgetKind,
matchingIds,
options,
@@ -118,7 +127,7 @@ export const placeAllWidgetsAsync = async (
for (const app of allApps) {
await placeWidgetAsync(
db,
- ctx,
+ contexts,
"app",
[],
superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }),
@@ -138,7 +147,7 @@ export const placeAllWidgetsAsync = async (
options = { ...options, items: bookmarkAppIds };
}
- await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, {
+ await placeWidgetAsync(db, contexts, config.kind, [], options ? superjson.stringify(options) : undefined, {
width: config.width,
height: config.height,
});
diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts
index 6280b8c79..310f35d03 100644
--- a/packages/db/schema/index.ts
+++ b/packages/db/schema/index.ts
@@ -19,6 +19,9 @@ export const schema = createSchema({
export const {
accounts,
apiKeys,
+ assistantConfigurations,
+ assistantMessages,
+ assistantThreads,
apps,
boardGroupPermissions,
boardUserPermissions,
@@ -58,6 +61,9 @@ export const {
export type User = InferSelectModel;
export type Account = InferSelectModel;
+export type AssistantConfiguration = InferSelectModel;
+export type AssistantMessage = InferSelectModel;
+export type AssistantThread = InferSelectModel;
export type Session = InferSelectModel;
export type VerificationToken = InferSelectModel;
export type Integration = InferSelectModel;
diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts
index ee891635c..df478c3fa 100644
--- a/packages/db/schema/mysql.ts
+++ b/packages/db/schema/mysql.ts
@@ -9,6 +9,7 @@ import {
foreignKey,
index,
int,
+ mediumtext,
mysqlTable,
primaryKey,
smallint,
@@ -34,6 +35,7 @@ import type {
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
+ LayoutRole,
OnboardingStep,
SearchEngineType,
SectionKind,
@@ -343,6 +345,7 @@ export const layouts = mysqlTable("layout", {
leftGutterColumnCount: tinyint().notNull().default(0),
rightGutterColumnCount: tinyint().notNull().default(0),
breakpoint: smallint().notNull().default(0),
+ role: varchar({ length: 16 }).$type().notNull().default("custom"),
});
export const itemLayouts = mysqlTable(
@@ -479,6 +482,71 @@ export const serverSettings = mysqlTable("serverSetting", {
value: text().default(emptySuperJSON).notNull(),
});
+export const assistantConfigurations = mysqlTable("assistant_configuration", {
+ id: varchar({ length: 64 }).notNull().primaryKey().default("default"),
+ enabled: boolean().notNull().default(false),
+ webSearchEnabled: boolean().notNull().default(false),
+ provider: varchar({ length: 32 })
+ .$type<
+ | "openrouter"
+ | "openai"
+ | "anthropic"
+ | "google-gemini"
+ | "xai"
+ | "groq"
+ | "mistral"
+ | "deepseek"
+ | "together"
+ | "ollama"
+ | "lm-studio"
+ | "custom"
+ >()
+ .notNull()
+ .default("openrouter"),
+ baseUrl: varchar({ length: 2048 }).notNull().default("https://openrouter.ai/api/v1"),
+ modelDiscoveryPath: varchar({ length: 512 }).default("/models"),
+ encryptedApiKey: text().$type<`${string}.${string}`>(),
+ encryptedHeaders: text().$type<`${string}.${string}`>(),
+ modelId: varchar({ length: 256 }),
+ updatedAt: timestamp().notNull().defaultNow(),
+});
+
+export const assistantThreads = mysqlTable(
+ "assistant_thread",
+ {
+ id: varchar({ length: 64 }).notNull().primaryKey(),
+ userId: varchar({ length: 64 })
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ title: varchar({ length: 256 }),
+ modelId: varchar({ length: 256 }),
+ createdAt: timestamp().notNull().defaultNow(),
+ updatedAt: timestamp().notNull().defaultNow(),
+ },
+ (thread) => ({
+ userUpdatedAtIdx: index("assistant_thread__user_id_updated_at_idx").on(thread.userId, thread.updatedAt),
+ }),
+);
+
+export const assistantMessages = mysqlTable(
+ "assistant_message",
+ {
+ id: varchar({ length: 128 }).notNull().primaryKey(),
+ threadId: varchar({ length: 64 })
+ .notNull()
+ .references(() => assistantThreads.id, { onDelete: "cascade" }),
+ parentId: varchar({ length: 128 }),
+ format: varchar({ length: 64 }).notNull().default("ai-sdk/v6"),
+ // `text` only holds 64KB on MySQL, which a single base64 image attachment already exceeds.
+ // `mediumtext` holds 16MB, comfortably above the 2MB request cap enforced by the chat route.
+ content: mediumtext().notNull(),
+ createdAt: timestamp().notNull().defaultNow(),
+ },
+ (message) => ({
+ threadCreatedAtIdx: index("assistant_message__thread_id_created_at_idx").on(message.threadId, message.createdAt),
+ }),
+);
+
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
user: one(users, {
fields: [apiKeys.userId],
diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts
index 559fbd9eb..c0c22ab88 100644
--- a/packages/db/schema/postgresql.ts
+++ b/packages/db/schema/postgresql.ts
@@ -33,6 +33,7 @@ import type {
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
+ LayoutRole,
OnboardingStep,
SearchEngineType,
SectionKind,
@@ -342,6 +343,7 @@ export const layouts = pgTable("layout", {
leftGutterColumnCount: smallint().notNull().default(0),
rightGutterColumnCount: smallint().notNull().default(0),
breakpoint: smallint().notNull().default(0),
+ role: varchar({ length: 16 }).$type().notNull().default("custom"),
});
export const itemLayouts = pgTable(
@@ -478,6 +480,69 @@ export const serverSettings = pgTable("serverSetting", {
value: text().default(emptySuperJSON).notNull(),
});
+export const assistantConfigurations = pgTable("assistant_configuration", {
+ id: varchar({ length: 64 }).notNull().primaryKey().default("default"),
+ enabled: boolean().notNull().default(false),
+ webSearchEnabled: boolean().notNull().default(false),
+ provider: varchar({ length: 32 })
+ .$type<
+ | "openrouter"
+ | "openai"
+ | "anthropic"
+ | "google-gemini"
+ | "xai"
+ | "groq"
+ | "mistral"
+ | "deepseek"
+ | "together"
+ | "ollama"
+ | "lm-studio"
+ | "custom"
+ >()
+ .notNull()
+ .default("openrouter"),
+ baseUrl: varchar({ length: 2048 }).notNull().default("https://openrouter.ai/api/v1"),
+ modelDiscoveryPath: varchar({ length: 512 }).default("/models"),
+ encryptedApiKey: text().$type<`${string}.${string}`>(),
+ encryptedHeaders: text().$type<`${string}.${string}`>(),
+ modelId: varchar({ length: 256 }),
+ updatedAt: timestamp().notNull().defaultNow(),
+});
+
+export const assistantThreads = pgTable(
+ "assistant_thread",
+ {
+ id: varchar({ length: 64 }).notNull().primaryKey(),
+ userId: varchar({ length: 64 })
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ title: varchar({ length: 256 }),
+ modelId: varchar({ length: 256 }),
+ createdAt: timestamp().notNull().defaultNow(),
+ updatedAt: timestamp().notNull().defaultNow(),
+ },
+ (thread) => ({
+ userUpdatedAtIdx: index("assistant_thread__user_id_updated_at_idx").on(thread.userId, thread.updatedAt),
+ }),
+);
+
+export const assistantMessages = pgTable(
+ "assistant_message",
+ {
+ id: varchar({ length: 128 }).notNull().primaryKey(),
+ threadId: varchar({ length: 64 })
+ .notNull()
+ .references(() => assistantThreads.id, { onDelete: "cascade" }),
+ parentId: varchar({ length: 128 }),
+ format: varchar({ length: 64 }).notNull().default("ai-sdk/v6"),
+ content: text().notNull(),
+ createdAt: timestamp().notNull().defaultNow(),
+ },
+ (message) => ({
+ threadCreatedAtIdx: index("assistant_message__thread_id_created_at_idx").on(message.threadId, message.createdAt),
+ }),
+);
+
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
user: one(users, {
fields: [apiKeys.userId],
diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts
index 59c5e1197..b04d4919c 100644
--- a/packages/db/schema/sqlite.ts
+++ b/packages/db/schema/sqlite.ts
@@ -21,6 +21,7 @@ import type {
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
+ LayoutRole,
OnboardingStep,
SearchEngineType,
SectionKind,
@@ -327,6 +328,7 @@ export const layouts = sqliteTable("layout", {
leftGutterColumnCount: int().notNull().default(0),
rightGutterColumnCount: int().notNull().default(0),
breakpoint: int().notNull().default(0),
+ role: text().$type().notNull().default("custom"),
});
export const itemLayouts = sqliteTable(
@@ -463,6 +465,77 @@ export const serverSettings = sqliteTable("serverSetting", {
value: text().default(emptySuperJSON).notNull(),
});
+export const assistantConfigurations = sqliteTable("assistant_configuration", {
+ id: text().notNull().primaryKey().default("default"),
+ enabled: int({ mode: "boolean" }).notNull().default(false),
+ webSearchEnabled: int({ mode: "boolean" }).notNull().default(false),
+ provider: text()
+ .$type<
+ | "openrouter"
+ | "openai"
+ | "anthropic"
+ | "google-gemini"
+ | "xai"
+ | "groq"
+ | "mistral"
+ | "deepseek"
+ | "together"
+ | "ollama"
+ | "lm-studio"
+ | "custom"
+ >()
+ .notNull()
+ .default("openrouter"),
+ baseUrl: text().notNull().default("https://openrouter.ai/api/v1"),
+ modelDiscoveryPath: text().default("/models"),
+ encryptedApiKey: text().$type<`${string}.${string}`>(),
+ encryptedHeaders: text().$type<`${string}.${string}`>(),
+ modelId: text(),
+ updatedAt: int({ mode: "timestamp" })
+ .notNull()
+ .default(sql`(unixepoch())`),
+});
+
+export const assistantThreads = sqliteTable(
+ "assistant_thread",
+ {
+ id: text().notNull().primaryKey(),
+ userId: text()
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ title: text(),
+ modelId: text(),
+ createdAt: int({ mode: "timestamp" })
+ .notNull()
+ .default(sql`(unixepoch())`),
+ updatedAt: int({ mode: "timestamp" })
+ .notNull()
+ .default(sql`(unixepoch())`),
+ },
+ (thread) => ({
+ userUpdatedAtIdx: index("assistant_thread__user_id_updated_at_idx").on(thread.userId, thread.updatedAt),
+ }),
+);
+
+export const assistantMessages = sqliteTable(
+ "assistant_message",
+ {
+ id: text().notNull().primaryKey(),
+ threadId: text()
+ .notNull()
+ .references(() => assistantThreads.id, { onDelete: "cascade" }),
+ parentId: text(),
+ format: text().notNull().default("ai-sdk/v6"),
+ content: text().notNull(),
+ createdAt: int({ mode: "timestamp" })
+ .notNull()
+ .default(sql`(unixepoch())`),
+ },
+ (message) => ({
+ threadCreatedAtIdx: index("assistant_message__thread_id_created_at_idx").on(message.threadId, message.createdAt),
+ }),
+);
+
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
user: one(users, {
fields: [apiKeys.userId],
diff --git a/packages/db/test/migration-journal.spec.ts b/packages/db/test/migration-journal.spec.ts
new file mode 100644
index 000000000..15b1e3a12
--- /dev/null
+++ b/packages/db/test/migration-journal.spec.ts
@@ -0,0 +1,98 @@
+import fs from "fs";
+import path from "path";
+import { describe, expect, test } from "vitest";
+
+/**
+ * Guards the migration folders against the failure modes that have actually shipped:
+ *
+ * - v1.70.0 shipped `0042_add_widget_secret.sql` for MySQL without a `_journal.json` entry, so the
+ * migrator never ran it and MySQL instances upgraded from that release had no `widget_secret`
+ * table.
+ * - The follow-up fix hand-wrote `0042_snapshot.json` with a `checkConstraints` key instead of
+ * `checkConstraint`, which made `drizzle-kit generate` reject the folder and silently exit 0 for
+ * every later MySQL migration.
+ */
+const dialects = ["mysql", "postgresql", "sqlite"] as const;
+
+const migrationsRoot = path.join(__dirname, "..", "migrations");
+
+interface Journal {
+ entries: { idx: number; tag: string }[];
+}
+
+const readDialect = (dialect: (typeof dialects)[number]) => {
+ const folder = path.join(migrationsRoot, dialect);
+ const journal = JSON.parse(fs.readFileSync(path.join(folder, "meta", "_journal.json"), "utf8")) as Journal;
+ const sqlFiles = fs
+ .readdirSync(folder)
+ .filter((file) => file.endsWith(".sql"))
+ .map((file) => file.replace(/\.sql$/u, ""));
+ const snapshots = fs
+ .readdirSync(path.join(folder, "meta"))
+ .filter((file) => /^\d+_snapshot\.json$/u.test(file))
+ .map((file) => file.split("_")[0]);
+
+ return { folder, journal, sqlFiles, snapshots };
+};
+
+describe.each(dialects)("%s migrations", (dialect) => {
+ test("every journal entry has a matching .sql file", () => {
+ const { journal, sqlFiles } = readDialect(dialect);
+ const missing = journal.entries.filter((entry) => !sqlFiles.includes(entry.tag)).map((entry) => entry.tag);
+
+ expect(missing).toEqual([]);
+ });
+
+ test("every .sql file is registered in the journal", () => {
+ const { journal, sqlFiles } = readDialect(dialect);
+ const tags = new Set(journal.entries.map((entry) => entry.tag));
+ const orphans = sqlFiles.filter((file) => !tags.has(file));
+
+ expect(orphans).toEqual([]);
+ });
+
+ test("every journal entry has a matching snapshot", () => {
+ const { journal, snapshots } = readDialect(dialect);
+ const missing = journal.entries
+ .filter((entry) => !snapshots.includes(String(entry.idx).padStart(4, "0")))
+ .map((entry) => entry.tag);
+
+ expect(missing).toEqual([]);
+ });
+
+ test("journal indexes are unique and gap-free", () => {
+ const { journal } = readDialect(dialect);
+ const indexes = journal.entries.map((entry) => entry.idx);
+
+ expect(indexes).toEqual(indexes.map((_, position) => position));
+ });
+
+ // The check-constraint key is spelled differently per snapshot format version (MySQL's v5 uses
+ // `checkConstraint`, the newer SQLite/Postgres formats use `checkConstraints`), so rather than
+ // hard-coding a spelling this asserts every snapshot in a dialect agrees with the others. A
+ // hand-written snapshot that gets it wrong stands out as the odd one and fails drizzle-kit.
+ test("all snapshots agree on the check-constraint key spelling", () => {
+ const { folder, snapshots } = readDialect(dialect);
+
+ const spellingsPerSnapshot = snapshots.map((snapshot) => {
+ const file = path.join(folder, "meta", `${snapshot}_snapshot.json`);
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { tables?: Record };
+ const spellings = new Set();
+
+ for (const table of Object.values(parsed.tables ?? {})) {
+ if ("checkConstraint" in table) spellings.add("checkConstraint");
+ if ("checkConstraints" in table) spellings.add("checkConstraints");
+ }
+
+ return { snapshot, spellings: [...spellings].toSorted().join("+") };
+ });
+
+ const used = [...new Set(spellingsPerSnapshot.map((entry) => entry.spellings))].filter(
+ (spelling) => spelling.length > 0,
+ );
+
+ expect(used, `snapshots disagree: ${JSON.stringify(spellingsPerSnapshot.filter((e) => e.spellings))}`).toHaveLength(
+ 1,
+ );
+ });
+});
diff --git a/packages/db/test/mysql-migration.spec.ts b/packages/db/test/mysql-migration.spec.ts
index 472c40c96..634c82524 100644
--- a/packages/db/test/mysql-migration.spec.ts
+++ b/packages/db/test/mysql-migration.spec.ts
@@ -1,6 +1,7 @@
import path from "path";
import { readFileSync } from "node:fs";
import { MySqlContainer } from "@testcontainers/mysql";
+import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import { migrate } from "drizzle-orm/mysql2/migrator";
import mysql from "mysql2";
@@ -56,6 +57,22 @@ describe("Mysql Migration", () => {
.query("SHOW TABLES LIKE 'custom_widget%'");
expect(customWidgetTables).toHaveLength(4);
+ // A single base64 image attachment already exceeds the 64KB that `text` holds on MySQL, so
+ // `assistant_message.content` has to be `mediumtext`. Everything below 64KB passes either way.
+ const largeContent = "x".repeat(200_000);
+ await database.insert(mysqlSchema.users).values({ id: "size-check-user", name: "size-check" });
+ await database.insert(mysqlSchema.assistantThreads).values({ id: "size-check-thread", userId: "size-check-user" });
+ await database.insert(mysqlSchema.assistantMessages).values({
+ id: "size-check-message",
+ threadId: "size-check-thread",
+ parentId: null,
+ content: largeContent,
+ });
+ const [storedMessage] = await database.query.assistantMessages.findMany({
+ where: eq(mysqlSchema.assistantMessages.id, "size-check-message"),
+ });
+ expect(storedMessage?.content).toHaveLength(largeContent.length);
+
connection.end();
await mysqlContainer.stop();
}, 120_000);
diff --git a/packages/db/test/schema.spec.ts b/packages/db/test/schema.spec.ts
index 5e1eeab98..f5503e516 100644
--- a/packages/db/test/schema.spec.ts
+++ b/packages/db/test/schema.spec.ts
@@ -1,8 +1,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable import/namespace -- This test compares equivalent schema exports by shared dynamic keys. */
+import { is } from "drizzle-orm";
import type { Column, InferSelectModel } from "drizzle-orm";
+import { MySqlTable, getTableConfig as getMysqlTableConfig } from "drizzle-orm/mysql-core";
import type { ForeignKey as MysqlForeignKey, MySqlTableWithColumns } from "drizzle-orm/mysql-core";
import type { PgTableWithColumns, ForeignKey as PostgresqlForeignKey } from "drizzle-orm/pg-core";
+import { SQLiteTable, getTableConfig as getSqliteTableConfig } from "drizzle-orm/sqlite-core";
import type { ForeignKey as SqliteForeignKey, SQLiteTableWithColumns } from "drizzle-orm/sqlite-core";
import { expect, expectTypeOf, test } from "vitest";
@@ -12,6 +15,9 @@ import * as mysqlSchema from "../schema/mysql";
import * as postgresqlSchema from "../schema/postgresql";
import * as sqliteSchema from "../schema/sqlite";
+const mysqlTables = { ...mysqlSchema };
+const postgresqlTables = { ...postgresqlSchema };
+
// We need the following three types as there is currently no support for Buffer in mysql & pg and
// so we use a custom type which results in the config beeing different
type FixedMysqlConfig = {
@@ -51,6 +57,21 @@ type FixedSqliteConfig = {
};
};
+/** Identifies a foreign key by what it links rather than by its generated name. */
+const referenceSignature = (foreignKey: {
+ reference: () => { columns: { name: string }[]; foreignTable: object; foreignColumns: { name: string }[] };
+}) => {
+ const reference = foreignKey.reference();
+ const target = Object.keys(reference.foreignTable).sort().join("+");
+ return `${reference.columns
+ .map((column) => column.name)
+ .sort()
+ .join(",")}->${target}(${reference.foreignColumns
+ .map((column) => column.name)
+ .sort()
+ .join(",")})`;
+};
+
test("schemas should match", () => {
expectTypeOf().toEqualTypeOf