aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2026-02-04 09:44:18 +0000
committerGitHub <noreply@github.com>2026-02-04 09:44:18 +0000
commit3c838ddb26c1e86d3f201ce71f13c834be705f69 (patch)
tree892fe4f8cd2ca01d6e4cd34f677fc16aa2fd63f6
parent3fcccb858ee3ef22fe9ce479af4ce458ac9a0fe1 (diff)
downloadkarakeep-3c838ddb26c1e86d3f201ce71f13c834be705f69.tar.zst
feat: Import workflow v3 (#2378)
* feat: import workflow v3 * batch stage * revert migration * cleanups * pr comments * move to models * add allowed workers * e2e tests * import list ids * add missing indicies * merge test * more fixes * add resume/pause to UI * fix ui states * fix tests * simplify progress tracking * remove backpressure * fix list imports * fix race on claiming bookmarks * remove the codex file
-rw-r--r--apps/web/components/settings/ImportSessionCard.tsx77
-rw-r--r--apps/web/lib/hooks/useBookmarkImport.ts104
-rw-r--r--apps/web/lib/hooks/useImportSessions.ts56
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json11
-rw-r--r--apps/workers/index.ts15
-rw-r--r--apps/workers/workers/importWorker.ts565
-rw-r--r--packages/db/drizzle/0077_import_listpaths_to_listids.sql26
-rw-r--r--packages/db/drizzle/0078_add_import_session_indexes.sql3
-rw-r--r--packages/db/drizzle/meta/0077_snapshot.json3211
-rw-r--r--packages/db/drizzle/meta/0078_snapshot.json3233
-rw-r--r--packages/db/drizzle/meta/_journal.json14
-rw-r--r--packages/db/schema.ts68
-rw-r--r--packages/e2e_tests/tests/workers/import.test.ts324
-rw-r--r--packages/shared/import-export/importer.test.ts414
-rw-r--r--packages/shared/import-export/importer.ts125
-rw-r--r--packages/shared/index.ts1
-rw-r--r--packages/shared/types/bookmarks.ts1
-rw-r--r--packages/shared/types/importSessions.ts6
-rw-r--r--packages/trpc/models/importSessions.ts174
-rw-r--r--packages/trpc/routers/bookmarks.ts13
-rw-r--r--packages/trpc/routers/importSessions.test.ts233
-rw-r--r--packages/trpc/routers/importSessions.ts109
22 files changed, 8300 insertions, 483 deletions
diff --git a/apps/web/components/settings/ImportSessionCard.tsx b/apps/web/components/settings/ImportSessionCard.tsx
index 690caaa5..f20710ca 100644
--- a/apps/web/components/settings/ImportSessionCard.tsx
+++ b/apps/web/components/settings/ImportSessionCard.tsx
@@ -9,6 +9,8 @@ import { Progress } from "@/components/ui/progress";
import {
useDeleteImportSession,
useImportSessionStats,
+ usePauseImportSession,
+ useResumeImportSession,
} from "@/lib/hooks/useImportSessions";
import { useTranslation } from "@/lib/i18n/client";
import { formatDistanceToNow } from "date-fns";
@@ -19,10 +21,17 @@ import {
Clock,
ExternalLink,
Loader2,
+ Pause,
+ Play,
Trash2,
+ Upload,
} from "lucide-react";
-import type { ZImportSessionWithStats } from "@karakeep/shared/types/importSessions";
+import type {
+ ZImportSessionStatus,
+ ZImportSessionWithStats,
+} from "@karakeep/shared/types/importSessions";
+import { switchCase } from "@karakeep/shared/utils/switch";
interface ImportSessionCardProps {
session: ZImportSessionWithStats;
@@ -30,10 +39,14 @@ interface ImportSessionCardProps {
function getStatusColor(status: string) {
switch (status) {
+ case "staging":
+ return "bg-purple-500/10 text-purple-700 dark:text-purple-400";
case "pending":
return "bg-muted text-muted-foreground";
- case "in_progress":
+ case "running":
return "bg-blue-500/10 text-blue-700 dark:text-blue-400";
+ case "paused":
+ return "bg-yellow-500/10 text-yellow-700 dark:text-yellow-400";
case "completed":
return "bg-green-500/10 text-green-700 dark:text-green-400";
case "failed":
@@ -45,10 +58,14 @@ function getStatusColor(status: string) {
function getStatusIcon(status: string) {
switch (status) {
+ case "staging":
+ return <Upload className="h-4 w-4" />;
case "pending":
return <Clock className="h-4 w-4" />;
- case "in_progress":
+ case "running":
return <Loader2 className="h-4 w-4 animate-spin" />;
+ case "paused":
+ return <Pause className="h-4 w-4" />;
case "completed":
return <CheckCircle2 className="h-4 w-4" />;
case "failed":
@@ -62,13 +79,18 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) {
const { t } = useTranslation();
const { data: liveStats } = useImportSessionStats(session.id);
const deleteSession = useDeleteImportSession();
+ const pauseSession = usePauseImportSession();
+ const resumeSession = useResumeImportSession();
- const statusLabels: Record<string, string> = {
- pending: t("settings.import_sessions.status.pending"),
- in_progress: t("settings.import_sessions.status.in_progress"),
- completed: t("settings.import_sessions.status.completed"),
- failed: t("settings.import_sessions.status.failed"),
- };
+ const statusLabels = (s: ZImportSessionStatus) =>
+ switchCase(s, {
+ staging: t("settings.import_sessions.status.staging"),
+ pending: t("settings.import_sessions.status.pending"),
+ running: t("settings.import_sessions.status.running"),
+ paused: t("settings.import_sessions.status.paused"),
+ completed: t("settings.import_sessions.status.completed"),
+ failed: t("settings.import_sessions.status.failed"),
+ });
// Use live stats if available, otherwise fallback to session stats
const stats = liveStats || session;
@@ -79,7 +101,14 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) {
100
: 0;
- const canDelete = stats.status !== "in_progress";
+ const canDelete =
+ stats.status === "completed" ||
+ stats.status === "failed" ||
+ stats.status === "paused";
+
+ const canPause = stats.status === "pending" || stats.status === "running";
+
+ const canResume = stats.status === "paused";
return (
<Card className="transition-all hover:shadow-md">
@@ -101,7 +130,7 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) {
>
{getStatusIcon(stats.status)}
<span className="ml-1 capitalize">
- {statusLabels[stats.status] ?? stats.status.replace("_", " ")}
+ {statusLabels(stats.status)}
</span>
</Badge>
</div>
@@ -213,6 +242,32 @@ export function ImportSessionCard({ session }: ImportSessionCardProps) {
{/* Actions */}
<div className="flex items-center justify-end pt-2">
<div className="flex items-center gap-2">
+ {canPause && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ pauseSession.mutate({ importSessionId: session.id })
+ }
+ disabled={pauseSession.isPending}
+ >
+ <Pause className="mr-1 h-4 w-4" />
+ {t("settings.import_sessions.pause_session")}
+ </Button>
+ )}
+ {canResume && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ resumeSession.mutate({ importSessionId: session.id })
+ }
+ disabled={resumeSession.isPending}
+ >
+ <Play className="mr-1 h-4 w-4" />
+ {t("settings.import_sessions.resume_session")}
+ </Button>
+ )}
{canDelete && (
<ActionConfirmingDialog
title={t("settings.import_sessions.delete_dialog_title")}
diff --git a/apps/web/lib/hooks/useBookmarkImport.ts b/apps/web/lib/hooks/useBookmarkImport.ts
index c0681924..35c04c1b 100644
--- a/apps/web/lib/hooks/useBookmarkImport.ts
+++ b/apps/web/lib/hooks/useBookmarkImport.ts
@@ -5,25 +5,13 @@ import { toast } from "@/components/ui/sonner";
import { useTranslation } from "@/lib/i18n/client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import {
- useCreateBookmarkWithPostHook,
- useUpdateBookmarkTags,
-} from "@karakeep/shared-react/hooks/bookmarks";
-import {
- useAddBookmarkToList,
- useCreateBookmarkList,
-} from "@karakeep/shared-react/hooks/lists";
+import { useCreateBookmarkList } from "@karakeep/shared-react/hooks/lists";
import { useTRPC } from "@karakeep/shared-react/trpc";
import {
importBookmarksFromFile,
ImportSource,
- ParsedBookmark,
parseImportFile,
} from "@karakeep/shared/import-export";
-import {
- BookmarkTypes,
- MAX_BOOKMARK_TITLE_LENGTH,
-} from "@karakeep/shared/types/bookmarks";
import { useCreateImportSession } from "./useImportSessions";
@@ -43,10 +31,13 @@ export function useBookmarkImport() {
const queryClient = useQueryClient();
const { mutateAsync: createImportSession } = useCreateImportSession();
- const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook();
const { mutateAsync: createList } = useCreateBookmarkList();
- const { mutateAsync: addToList } = useAddBookmarkToList();
- const { mutateAsync: updateTags } = useUpdateBookmarkTags();
+ const { mutateAsync: stageImportedBookmarks } = useMutation(
+ api.importSessions.stageImportedBookmarks.mutationOptions(),
+ );
+ const { mutateAsync: finalizeImportStaging } = useMutation(
+ api.importSessions.finalizeImportStaging.mutationOptions(),
+ );
const uploadBookmarkFileMutation = useMutation({
mutationFn: async ({
@@ -86,7 +77,6 @@ export function useBookmarkImport() {
}
// Proceed with import if quota check passes
- // Use a custom parser to avoid re-parsing the file
const result = await importBookmarksFromFile(
{
file,
@@ -95,65 +85,9 @@ export function useBookmarkImport() {
deps: {
createImportSession,
createList,
- createBookmark: async (
- bookmark: ParsedBookmark,
- sessionId: string,
- ) => {
- if (bookmark.content === undefined) {
- throw new Error("Content is undefined");
- }
- const created = await createBookmark({
- crawlPriority: "low",
- title: bookmark.title.substring(0, MAX_BOOKMARK_TITLE_LENGTH),
- createdAt: bookmark.addDate
- ? new Date(bookmark.addDate * 1000)
- : undefined,
- note: bookmark.notes,
- archived: bookmark.archived,
- importSessionId: sessionId,
- source: "import",
- ...(bookmark.content.type === BookmarkTypes.LINK
- ? {
- type: BookmarkTypes.LINK,
- url: bookmark.content.url,
- }
- : {
- type: BookmarkTypes.TEXT,
- text: bookmark.content.text,
- }),
- });
- return created as { id: string; alreadyExists?: boolean };
- },
- addBookmarkToLists: async ({
- bookmarkId,
- listIds,
- }: {
- bookmarkId: string;
- listIds: string[];
- }) => {
- await Promise.all(
- listIds.map((listId) =>
- addToList({
- bookmarkId,
- listId,
- }),
- ),
- );
- },
- updateBookmarkTags: async ({
- bookmarkId,
- tags,
- }: {
- bookmarkId: string;
- tags: string[];
- }) => {
- if (tags.length > 0) {
- await updateTags({
- bookmarkId,
- attach: tags.map((t) => ({ tagName: t })),
- detach: [],
- });
- }
+ stageImportedBookmarks,
+ finalizeImportStaging: async (sessionId: string) => {
+ await finalizeImportStaging({ importSessionId: sessionId });
},
},
onProgress: (done, total) => setImportProgress({ done, total }),
@@ -174,19 +108,11 @@ export function useBookmarkImport() {
toast({ description: "No bookmarks found in the file." });
return;
}
- const { successes, failures, alreadyExisted } = result.counts;
- if (successes > 0 || alreadyExisted > 0) {
- toast({
- description: `Imported ${successes} bookmarks into import session. Background processing will start automatically.`,
- variant: "default",
- });
- }
- if (failures > 0) {
- toast({
- description: `Failed to import ${failures} bookmarks. Check console for details.`,
- variant: "destructive",
- });
- }
+
+ toast({
+ description: `Staged ${result.counts.total} bookmarks for import. Background processing will start automatically.`,
+ variant: "default",
+ });
},
onError: (error) => {
setImportProgress(null);
diff --git a/apps/web/lib/hooks/useImportSessions.ts b/apps/web/lib/hooks/useImportSessions.ts
index 133bb29b..4d095c0b 100644
--- a/apps/web/lib/hooks/useImportSessions.ts
+++ b/apps/web/lib/hooks/useImportSessions.ts
@@ -46,7 +46,11 @@ export function useImportSessionStats(importSessionId: string) {
importSessionId,
},
{
- refetchInterval: 5000, // Refetch every 5 seconds to show progress
+ refetchInterval: (q) =>
+ !q.state.data ||
+ !["completed", "failed"].includes(q.state.data.status)
+ ? 5000
+ : false, // Refetch every 5 seconds to show progress
enabled: !!importSessionId,
},
),
@@ -77,3 +81,53 @@ export function useDeleteImportSession() {
}),
);
}
+
+export function usePauseImportSession() {
+ const api = useTRPC();
+ const queryClient = useQueryClient();
+
+ return useMutation(
+ api.importSessions.pauseImportSession.mutationOptions({
+ onSuccess: () => {
+ queryClient.invalidateQueries(
+ api.importSessions.listImportSessions.pathFilter(),
+ );
+ toast({
+ description: "Import session paused",
+ variant: "default",
+ });
+ },
+ onError: (error) => {
+ toast({
+ description: error.message || "Failed to pause import session",
+ variant: "destructive",
+ });
+ },
+ }),
+ );
+}
+
+export function useResumeImportSession() {
+ const api = useTRPC();
+ const queryClient = useQueryClient();
+
+ return useMutation(
+ api.importSessions.resumeImportSession.mutationOptions({
+ onSuccess: () => {
+ queryClient.invalidateQueries(
+ api.importSessions.listImportSessions.pathFilter(),
+ );
+ toast({
+ description: "Import session resumed",
+ variant: "default",
+ });
+ },
+ onError: (error) => {
+ toast({
+ description: error.message || "Failed to resume import session",
+ variant: "destructive",
+ });
+ },
+ }),
+ );
+}
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index ce607920..59d2098e 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -416,11 +416,12 @@
"created_at": "Created {{time}}",
"progress": "Progress",
"status": {
+ "staging": "Staging",
"pending": "Pending",
- "in_progress": "In progress",
+ "running": "Running",
+ "paused": "Paused",
"completed": "Completed",
- "failed": "Failed",
- "processing": "Processing"
+ "failed": "Failed"
},
"badges": {
"pending": "{{count}} pending",
@@ -432,7 +433,9 @@
"view_list": "View List",
"delete_dialog_title": "Delete Import Session",
"delete_dialog_description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone. The bookmarks themselves will not be deleted.",
- "delete_session": "Delete Session"
+ "delete_session": "Delete Session",
+ "pause_session": "Pause",
+ "resume_session": "Resume"
},
"backups": {
"backups": "Backups",
diff --git a/apps/workers/index.ts b/apps/workers/index.ts
index dfbac85b..931a505f 100644
--- a/apps/workers/index.ts
+++ b/apps/workers/index.ts
@@ -28,6 +28,7 @@ import { AssetPreprocessingWorker } from "./workers/assetPreprocessingWorker";
import { BackupSchedulingWorker, BackupWorker } from "./workers/backupWorker";
import { CrawlerWorker } from "./workers/crawlerWorker";
import { FeedRefreshingWorker, FeedWorker } from "./workers/feedWorker";
+import { ImportWorker } from "./workers/importWorker";
import { OpenAiWorker } from "./workers/inference/inferenceWorker";
import { RuleEngineWorker } from "./workers/ruleEngineWorker";
import { SearchIndexingWorker } from "./workers/searchWorker";
@@ -77,7 +78,7 @@ const workerBuilders = {
},
} as const;
-type WorkerName = keyof typeof workerBuilders;
+type WorkerName = keyof typeof workerBuilders | "import";
const enabledWorkers = new Set(serverConfig.workers.enabledWorkers);
const disabledWorkers = new Set(serverConfig.workers.disabledWorkers);
@@ -118,10 +119,19 @@ async function main() {
BackupSchedulingWorker.start();
}
+ // Start import polling worker
+ let importWorker: ImportWorker | null = null;
+ let importWorkerPromise: Promise<void> | null = null;
+ if (isWorkerEnabled("import")) {
+ importWorker = new ImportWorker();
+ importWorkerPromise = importWorker.start();
+ }
+
await Promise.any([
Promise.all([
...workers.map(({ worker }) => worker.run()),
httpServer.serve(),
+ ...(importWorkerPromise ? [importWorkerPromise] : []),
]),
shutdownPromise,
]);
@@ -136,6 +146,9 @@ async function main() {
if (workers.some((w) => w.name === "backup")) {
BackupSchedulingWorker.stop();
}
+ if (importWorker) {
+ importWorker.stop();
+ }
for (const { worker } of workers) {
worker.stop();
}
diff --git a/apps/workers/workers/importWorker.ts b/apps/workers/workers/importWorker.ts
new file mode 100644
index 00000000..a717fd9d
--- /dev/null
+++ b/apps/workers/workers/importWorker.ts
@@ -0,0 +1,565 @@
+import {
+ and,
+ count,
+ eq,
+ inArray,
+ isNotNull,
+ isNull,
+ lt,
+ or,
+} from "drizzle-orm";
+import { Counter, Gauge, Histogram } from "prom-client";
+import { buildImpersonatingTRPCClient } from "trpc";
+
+import { db } from "@karakeep/db";
+import {
+ bookmarkLinks,
+ bookmarks,
+ importSessions,
+ importStagingBookmarks,
+} from "@karakeep/db/schema";
+import logger from "@karakeep/shared/logger";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+
+// Prometheus metrics
+const importStagingProcessedCounter = new Counter({
+ name: "import_staging_processed_total",
+ help: "Total number of staged items processed",
+ labelNames: ["result"],
+});
+
+const importStagingStaleResetCounter = new Counter({
+ name: "import_staging_stale_reset_total",
+ help: "Total number of stale processing items reset to pending",
+});
+
+const importStagingInFlightGauge = new Gauge({
+ name: "import_staging_in_flight",
+ help: "Current number of in-flight items (processing + recently completed)",
+});
+
+const importSessionsGauge = new Gauge({
+ name: "import_sessions_active",
+ help: "Number of active import sessions by status",
+ labelNames: ["status"],
+});
+
+const importStagingPendingGauge = new Gauge({
+ name: "import_staging_pending_total",
+ help: "Total number of pending items in staging table",
+});
+
+const importBatchDurationHistogram = new Histogram({
+ name: "import_batch_duration_seconds",
+ help: "Time taken to process a batch of staged items",
+ buckets: [0.1, 0.5, 1, 2, 5, 10, 30],
+});
+
+function sleep(ms: number): Promise<void> {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+export class ImportWorker {
+ private running = false;
+ private pollIntervalMs = 1000;
+
+ // Backpressure settings
+ private maxInFlight = 50;
+ private batchSize = 10;
+ private staleThresholdMs = 60 * 60 * 1000; // 1 hour
+
+ async start() {
+ this.running = true;
+ let iterationCount = 0;
+
+ logger.info("[import] Starting import polling worker");
+
+ while (this.running) {
+ try {
+ // Periodically reset stale processing items (every 60 iterations ~= 1 min)
+ if (iterationCount % 60 === 0) {
+ await this.resetStaleProcessingItems();
+ }
+ iterationCount++;
+
+ // Check if any processing items have completed downstream work
+ await this.checkAndCompleteProcessingItems();
+
+ const processed = await this.processBatch();
+ if (processed === 0) {
+ await this.checkAndCompleteIdleSessions();
+ // Nothing to do, wait before polling again
+ await sleep(this.pollIntervalMs);
+ }
+ } catch (error) {
+ logger.error(`[import] Error in polling loop: ${error}`);
+ await sleep(this.pollIntervalMs);
+ }
+ }
+ }
+
+ stop() {
+ logger.info("[import] Stopping import polling worker");
+ this.running = false;
+ }
+
+ private async processBatch(): Promise<number> {
+ // 1. Check backpressure - count in-flight + recently completed items
+ const availableCapacity = await this.getAvailableCapacity();
+ importStagingInFlightGauge.set(this.maxInFlight - availableCapacity);
+
+ if (availableCapacity <= 0) {
+ // At capacity, wait before trying again
+ return 0;
+ }
+
+ // 2. Get candidate IDs with fair scheduling across users
+ const batchLimit = Math.min(this.batchSize, availableCapacity);
+ const candidateIds = await this.getNextBatchFairly(batchLimit);
+
+ if (candidateIds.length === 0) return 0;
+
+ // 3. Atomically claim rows - only rows still pending will be claimed
+ // This prevents race conditions where multiple workers select the same rows
+ const batch = await db
+ .update(importStagingBookmarks)
+ .set({ status: "processing", processingStartedAt: new Date() })
+ .where(
+ and(
+ eq(importStagingBookmarks.status, "pending"),
+ inArray(importStagingBookmarks.id, candidateIds),
+ ),
+ )
+ .returning();
+
+ // If no rows were claimed (another worker got them first), skip processing
+ if (batch.length === 0) return 0;
+
+ const batchTimer = importBatchDurationHistogram.startTimer();
+
+ // 4. Mark session(s) as running (using claimed rows, not candidates)
+ const sessionIds = [...new Set(batch.map((b) => b.importSessionId))];
+ await db
+ .update(importSessions)
+ .set({ status: "running" })
+ .where(
+ and(
+ inArray(importSessions.id, sessionIds),
+ eq(importSessions.status, "pending"),
+ ),
+ );
+
+ // 5. Process in parallel
+ await Promise.allSettled(
+ batch.map((staged) => this.processOneBookmark(staged)),
+ );
+
+ // 6. Check if any sessions are now complete
+ await this.checkAndCompleteEmptySessions(sessionIds);
+
+ batchTimer(); // Record batch duration
+ await this.updateGauges(); // Update pending/session gauges
+
+ return batch.length;
+ }
+
+ private async updateGauges() {
+ // Update pending items gauge
+ const pending = await db
+ .select({ count: count() })
+ .from(importStagingBookmarks)
+ .where(eq(importStagingBookmarks.status, "pending"));
+ importStagingPendingGauge.set(pending[0]?.count ?? 0);
+
+ // Update active sessions gauge by status
+ const sessions = await db
+ .select({
+ status: importSessions.status,
+ count: count(),
+ })
+ .from(importSessions)
+ .where(
+ inArray(importSessions.status, [
+ "staging",
+ "pending",
+ "running",
+ "paused",
+ ]),
+ )
+ .groupBy(importSessions.status);
+
+ // Reset all status gauges to 0 first
+ for (const status of ["staging", "pending", "running", "paused"]) {
+ importSessionsGauge.set({ status }, 0);
+ }
+
+ // Set actual values
+ for (const s of sessions) {
+ importSessionsGauge.set({ status: s.status }, s.count);
+ }
+ }
+
+ private async checkAndCompleteIdleSessions() {
+ const sessions = await db
+ .select({ id: importSessions.id })
+ .from(importSessions)
+ .where(inArray(importSessions.status, ["pending", "running"]));
+
+ const sessionIds = sessions.map((session) => session.id);
+ if (sessionIds.length === 0) {
+ return;
+ }
+
+ await this.checkAndCompleteEmptySessions(sessionIds);
+ }
+
+ private async getNextBatchFairly(limit: number): Promise<string[]> {
+ // Query pending item IDs from active sessions, ordered by:
+ // 1. User's last-served timestamp (fairness)
+ // 2. Staging item creation time (FIFO within user)
+ // Returns only IDs - actual rows will be fetched atomically during claim
+ const results = await db
+ .select({
+ id: importStagingBookmarks.id,
+ })
+ .from(importStagingBookmarks)
+ .innerJoin(
+ importSessions,
+ eq(importStagingBookmarks.importSessionId, importSessions.id),
+ )
+ .where(
+ and(
+ eq(importStagingBookmarks.status, "pending"),
+ inArray(importSessions.status, ["pending", "running"]),
+ ),
+ )
+ .orderBy(importSessions.lastProcessedAt, importStagingBookmarks.createdAt)
+ .limit(limit);
+
+ return results.map((r) => r.id);
+ }
+
+ private async attachBookmarkToLists(
+ caller: Awaited<ReturnType<typeof buildImpersonatingTRPCClient>>,
+ session: typeof importSessions.$inferSelect,
+ staged: typeof importStagingBookmarks.$inferSelect,
+ bookmarkId: string,
+ ): Promise<void> {
+ const listIds = new Set<string>();
+
+ if (session.rootListId) {
+ listIds.add(session.rootListId);
+ }
+
+ if (staged.listIds && staged.listIds.length > 0) {
+ for (const listId of staged.listIds) {
+ listIds.add(listId);
+ }
+ }
+
+ for (const listId of listIds) {
+ try {
+ await caller.lists.addToList({ listId, bookmarkId });
+ } catch (error) {
+ logger.warn(
+ `[import] Failed to add bookmark ${bookmarkId} to list ${listId}: ${error}`,
+ );
+ }
+ }
+ }
+
+ private async processOneBookmark(
+ staged: typeof importStagingBookmarks.$inferSelect,
+ ) {
+ const session = await db.query.importSessions.findFirst({
+ where: eq(importSessions.id, staged.importSessionId),
+ });
+
+ if (!session || session.status === "paused") {
+ // Session paused mid-batch, reset item to pending
+ await db
+ .update(importStagingBookmarks)
+ .set({ status: "pending" })
+ .where(eq(importStagingBookmarks.id, staged.id));
+ return;
+ }
+
+ try {
+ // Use existing tRPC mutation via internal caller
+ // Note: Duplicate detection is handled by createBookmark itself
+ const caller = await buildImpersonatingTRPCClient(session.userId);
+
+ // Build the request based on bookmark type
+ type CreateBookmarkInput = Parameters<
+ typeof caller.bookmarks.createBookmark
+ >[0];
+
+ const baseRequest = {
+ title: staged.title ?? undefined,
+ note: staged.note ?? undefined,
+ createdAt: staged.sourceAddedAt ?? undefined,
+ crawlPriority: "low" as const,
+ };
+
+ let bookmarkRequest: CreateBookmarkInput;
+
+ if (staged.type === "link") {
+ if (!staged.url) {
+ throw new Error("URL is required for link bookmarks");
+ }
+ bookmarkRequest = {
+ ...baseRequest,
+ type: BookmarkTypes.LINK,
+ url: staged.url,
+ };
+ } else if (staged.type === "text") {
+ if (!staged.content) {
+ throw new Error("Content is required for text bookmarks");
+ }
+ bookmarkRequest = {
+ ...baseRequest,
+ type: BookmarkTypes.TEXT,
+ text: staged.content,
+ };
+ } else {
+ // asset type - skip for now as it needs special handling
+ logger.warn(
+ `[import] Asset bookmarks not yet supported in import worker: ${staged.id}`,
+ );
+ await db
+ .update(importStagingBookmarks)
+ .set({
+ status: "failed",
+ result: "rejected",
+ resultReason: "Asset bookmarks not yet supported",
+ completedAt: new Date(),
+ })
+ .where(eq(importStagingBookmarks.id, staged.id));
+ await this.updateSessionLastProcessedAt(staged.importSessionId);
+ return;
+ }
+
+ const result = await caller.bookmarks.createBookmark(bookmarkRequest);
+
+ // Apply tags via existing mutation (for both new and duplicate bookmarks)
+ if (staged.tags && staged.tags.length > 0) {
+ await caller.bookmarks.updateTags({
+ bookmarkId: result.id,
+ attach: staged.tags.map((t) => ({ tagName: t })),
+ detach: [],
+ });
+ }
+
+ // Handle duplicate case (createBookmark returns alreadyExists: true)
+ if (result.alreadyExists) {
+ await db
+ .update(importStagingBookmarks)
+ .set({
+ status: "completed",
+ result: "skipped_duplicate",
+ resultReason: "URL already exists",
+ resultBookmarkId: result.id,
+ completedAt: new Date(),
+ })
+ .where(eq(importStagingBookmarks.id, staged.id));
+
+ importStagingProcessedCounter.inc({ result: "skipped_duplicate" });
+ await this.attachBookmarkToLists(caller, session, staged, result.id);
+ await this.updateSessionLastProcessedAt(staged.importSessionId);
+ return;
+ }
+
+ // Mark as accepted but keep in "processing" until crawl/tag is done
+ // The item will be moved to "completed" by checkAndCompleteProcessingItems()
+ await db
+ .update(importStagingBookmarks)
+ .set({
+ result: "accepted",
+ resultBookmarkId: result.id,
+ })
+ .where(eq(importStagingBookmarks.id, staged.id));
+
+ await this.attachBookmarkToLists(caller, session, staged, result.id);
+
+ await this.updateSessionLastProcessedAt(staged.importSessionId);
+ } catch (error) {
+ logger.error(
+ `[import] Error processing staged item ${staged.id}: ${error}`,
+ );
+ await db
+ .update(importStagingBookmarks)
+ .set({
+ status: "failed",
+ result: "rejected",
+ resultReason: String(error),
+ completedAt: new Date(),
+ })
+ .where(eq(importStagingBookmarks.id, staged.id));
+
+ importStagingProcessedCounter.inc({ result: "rejected" });
+ await this.updateSessionLastProcessedAt(staged.importSessionId);
+ }
+ }
+
+ private async updateSessionLastProcessedAt(sessionId: string) {
+ await db
+ .update(importSessions)
+ .set({ lastProcessedAt: new Date() })
+ .where(eq(importSessions.id, sessionId));
+ }
+
+ private async checkAndCompleteEmptySessions(sessionIds: string[]) {
+ for (const sessionId of sessionIds) {
+ const remaining = await db
+ .select({ count: count() })
+ .from(importStagingBookmarks)
+ .where(
+ and(
+ eq(importStagingBookmarks.importSessionId, sessionId),
+ inArray(importStagingBookmarks.status, ["pending", "processing"]),
+ ),
+ );
+
+ if (remaining[0]?.count === 0) {
+ await db
+ .update(importSessions)
+ .set({ status: "completed" })
+ .where(eq(importSessions.id, sessionId));
+ }
+ }
+ }
+
+ /**
+ * Check processing items that have a bookmark created and mark them as completed
+ * once downstream processing (crawling/tagging) is done.
+ */
+ private async checkAndCompleteProcessingItems(): Promise<number> {
+ // Find processing items where:
+ // - A bookmark was created (resultBookmarkId is set)
+ // - Downstream processing is complete (crawl/tag not pending)
+ const completedItems = await db
+ .select({
+ id: importStagingBookmarks.id,
+ importSessionId: importStagingBookmarks.importSessionId,
+ })
+ .from(importStagingBookmarks)
+ .leftJoin(
+ bookmarks,
+ eq(bookmarks.id, importStagingBookmarks.resultBookmarkId),
+ )
+ .leftJoin(
+ bookmarkLinks,
+ eq(bookmarkLinks.id, importStagingBookmarks.resultBookmarkId),
+ )
+ .where(
+ and(
+ eq(importStagingBookmarks.status, "processing"),
+ isNotNull(importStagingBookmarks.resultBookmarkId),
+ // Crawl is done (not pending) - either success, failure, or null (not a link)
+ or(
+ isNull(bookmarkLinks.crawlStatus),
+ eq(bookmarkLinks.crawlStatus, "success"),
+ eq(bookmarkLinks.crawlStatus, "failure"),
+ ),
+ // Tagging is done (not pending) - either success, failure, or null
+ or(
+ isNull(bookmarks.taggingStatus),
+ eq(bookmarks.taggingStatus, "success"),
+ eq(bookmarks.taggingStatus, "failure"),
+ ),
+ ),
+ );
+
+ if (completedItems.length === 0) {
+ return 0;
+ }
+
+ // Mark them as completed
+ await db
+ .update(importStagingBookmarks)
+ .set({
+ status: "completed",
+ completedAt: new Date(),
+ })
+ .where(
+ inArray(
+ importStagingBookmarks.id,
+ completedItems.map((i) => i.id),
+ ),
+ );
+
+ // Increment counter for completed items
+ importStagingProcessedCounter.inc(
+ { result: "accepted" },
+ completedItems.length,
+ );
+
+ // Check if any sessions are now complete
+ const sessionIds = [
+ ...new Set(completedItems.map((i) => i.importSessionId)),
+ ];
+ await this.checkAndCompleteEmptySessions(sessionIds);
+
+ return completedItems.length;
+ }
+
+ /**
+ * Backpressure: Calculate available capacity.
+ * Counts items currently in "processing" status, which includes both:
+ * - Items being actively imported
+ * - Items waiting for downstream crawl/tag to complete
+ */
+ private async getAvailableCapacity(): Promise<number> {
+ const processing = await db
+ .select({ count: count() })
+ .from(importStagingBookmarks)
+ .where(eq(importStagingBookmarks.status, "processing"));
+
+ return this.maxInFlight - (processing[0]?.count ?? 0);
+ }
+
+ /**
+ * Reset stale "processing" items back to "pending" so they can be retried.
+ * Called periodically to handle crashed workers or stuck items.
+ *
+ * Only resets items that don't have a resultBookmarkId - those with a bookmark
+ * are waiting for downstream processing (crawl/tag), not stale.
+ */
+ private async resetStaleProcessingItems(): Promise<number> {
+ const staleThreshold = new Date(Date.now() - this.staleThresholdMs);
+
+ const staleItems = await db
+ .select({ id: importStagingBookmarks.id })
+ .from(importStagingBookmarks)
+ .where(
+ and(
+ eq(importStagingBookmarks.status, "processing"),
+ lt(importStagingBookmarks.processingStartedAt, staleThreshold),
+ // Only reset items that haven't created a bookmark yet
+ // Items with a bookmark are waiting for downstream, not stale
+ isNull(importStagingBookmarks.resultBookmarkId),
+ ),
+ );
+
+ if (staleItems.length > 0) {
+ logger.warn(
+ `[import] Resetting ${staleItems.length} stale processing items`,
+ );
+
+ await db
+ .update(importStagingBookmarks)
+ .set({ status: "pending", processingStartedAt: null })
+ .where(
+ inArray(
+ importStagingBookmarks.id,
+ staleItems.map((i) => i.id),
+ ),
+ );
+
+ importStagingStaleResetCounter.inc(staleItems.length);
+ return staleItems.length;
+ }
+
+ return 0;
+ }
+}
diff --git a/packages/db/drizzle/0077_import_listpaths_to_listids.sql b/packages/db/drizzle/0077_import_listpaths_to_listids.sql
new file mode 100644
index 00000000..f1c4f883
--- /dev/null
+++ b/packages/db/drizzle/0077_import_listpaths_to_listids.sql
@@ -0,0 +1,26 @@
+CREATE TABLE `importStagingBookmarks` (
+ `id` text PRIMARY KEY NOT NULL,
+ `importSessionId` text NOT NULL,
+ `type` text NOT NULL,
+ `url` text,
+ `title` text,
+ `content` text,
+ `note` text,
+ `tags` text,
+ `listIds` text,
+ `sourceAddedAt` integer,
+ `status` text DEFAULT 'pending' NOT NULL,
+ `processingStartedAt` integer,
+ `result` text,
+ `resultReason` text,
+ `resultBookmarkId` text,
+ `createdAt` integer NOT NULL,
+ `completedAt` integer,
+ FOREIGN KEY (`importSessionId`) REFERENCES `importSessions`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`resultBookmarkId`) REFERENCES `bookmarks`(`id`) ON UPDATE no action ON DELETE set null
+);
+--> statement-breakpoint
+CREATE INDEX `importStaging_session_status_idx` ON `importStagingBookmarks` (`importSessionId`,`status`);--> statement-breakpoint
+CREATE INDEX `importStaging_completedAt_idx` ON `importStagingBookmarks` (`completedAt`);--> statement-breakpoint
+ALTER TABLE `importSessions` ADD `status` text DEFAULT 'staging' NOT NULL;--> statement-breakpoint
+ALTER TABLE `importSessions` ADD `lastProcessedAt` integer; \ No newline at end of file
diff --git a/packages/db/drizzle/0078_add_import_session_indexes.sql b/packages/db/drizzle/0078_add_import_session_indexes.sql
new file mode 100644
index 00000000..84a52652
--- /dev/null
+++ b/packages/db/drizzle/0078_add_import_session_indexes.sql
@@ -0,0 +1,3 @@
+CREATE INDEX `importSessions_status_idx` ON `importSessions` (`status`);--> statement-breakpoint
+CREATE INDEX `importStaging_status_idx` ON `importStagingBookmarks` (`status`);--> statement-breakpoint
+CREATE INDEX `importStaging_status_processingStartedAt_idx` ON `importStagingBookmarks` (`status`,`processingStartedAt`); \ No newline at end of file
diff --git a/packages/db/drizzle/meta/0077_snapshot.json b/packages/db/drizzle/meta/0077_snapshot.json
new file mode 100644
index 00000000..6b567a35
--- /dev/null
+++ b/packages/db/drizzle/meta/0077_snapshot.json
@@ -0,0 +1,3211 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "49a682e5-a215-43da-ac0f-dd1a2ccea538",
+ "prevId": "609c4402-c36e-4efc-9fd0-4f9038f8a164",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "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
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "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": {},
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "columns": [
+ "provider",
+ "providerAccountId"
+ ],
+ "name": "account_provider_providerAccountId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lastUsedAt": {
+ "name": "lastUsedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "keyId": {
+ "name": "keyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apiKey_keyId_unique": {
+ "name": "apiKey_keyId_unique",
+ "columns": [
+ "keyId"
+ ],
+ "isUnique": true
+ },
+ "apiKey_name_userId_unique": {
+ "name": "apiKey_name_userId_unique",
+ "columns": [
+ "name",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "apiKey_userId_user_id_fk": {
+ "name": "apiKey_userId_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "assets": {
+ "name": "assets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "assets_bookmarkId_idx": {
+ "name": "assets_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "assets_assetType_idx": {
+ "name": "assets_assetType_idx",
+ "columns": [
+ "assetType"
+ ],
+ "isUnique": false
+ },
+ "assets_userId_idx": {
+ "name": "assets_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assets_bookmarkId_bookmarks_id_fk": {
+ "name": "assets_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "assets_userId_user_id_fk": {
+ "name": "assets_userId_user_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "backups": {
+ "name": "backups",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetId": {
+ "name": "assetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkCount": {
+ "name": "bookmarkCount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "errorMessage": {
+ "name": "errorMessage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "backups_userId_idx": {
+ "name": "backups_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "backups_createdAt_idx": {
+ "name": "backups_createdAt_idx",
+ "columns": [
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "backups_userId_user_id_fk": {
+ "name": "backups_userId_user_id_fk",
+ "tableFrom": "backups",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "backups_assetId_assets_id_fk": {
+ "name": "backups_assetId_assets_id_fk",
+ "tableFrom": "backups",
+ "tableTo": "assets",
+ "columnsFrom": [
+ "assetId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkAssets": {
+ "name": "bookmarkAssets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetId": {
+ "name": "assetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkAssets_id_bookmarks_id_fk": {
+ "name": "bookmarkAssets_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkAssets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLinks": {
+ "name": "bookmarkLinks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "author": {
+ "name": "author",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "publisher": {
+ "name": "publisher",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "datePublished": {
+ "name": "datePublished",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dateModified": {
+ "name": "dateModified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imageUrl": {
+ "name": "imageUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon": {
+ "name": "favicon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "htmlContent": {
+ "name": "htmlContent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contentAssetId": {
+ "name": "contentAssetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawledAt": {
+ "name": "crawledAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawlStatus": {
+ "name": "crawlStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "crawlStatusCode": {
+ "name": "crawlStatusCode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 200
+ }
+ },
+ "indexes": {
+ "bookmarkLinks_url_idx": {
+ "name": "bookmarkLinks_url_idx",
+ "columns": [
+ "url"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLinks_id_bookmarks_id_fk": {
+ "name": "bookmarkLinks_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkLinks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLists": {
+ "name": "bookmarkLists",
+ "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": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "query": {
+ "name": "query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "parentId": {
+ "name": "parentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rssToken": {
+ "name": "rssToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public": {
+ "name": "public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "bookmarkLists_userId_idx": {
+ "name": "bookmarkLists_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkLists_userId_id_idx": {
+ "name": "bookmarkLists_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLists_userId_user_id_fk": {
+ "name": "bookmarkLists_userId_user_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarkLists_parentId_bookmarkLists_id_fk": {
+ "name": "bookmarkLists_parentId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "parentId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTags": {
+ "name": "bookmarkTags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "normalizedName": {
+ "name": "normalizedName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "generated": {
+ "as": "(lower(replace(replace(replace(\"name\", ' ', ''), '-', ''), '_', '')))",
+ "type": "virtual"
+ }
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarkTags_name_idx": {
+ "name": "bookmarkTags_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_idx": {
+ "name": "bookmarkTags_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_normalizedName_idx": {
+ "name": "bookmarkTags_normalizedName_idx",
+ "columns": [
+ "normalizedName"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_name_unique": {
+ "name": "bookmarkTags_userId_name_unique",
+ "columns": [
+ "userId",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "bookmarkTags_userId_id_idx": {
+ "name": "bookmarkTags_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkTags_userId_user_id_fk": {
+ "name": "bookmarkTags_userId_user_id_fk",
+ "tableFrom": "bookmarkTags",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTexts": {
+ "name": "bookmarkTexts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkTexts_id_bookmarks_id_fk": {
+ "name": "bookmarkTexts_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkTexts",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarks": {
+ "name": "bookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived": {
+ "name": "archived",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "favourited": {
+ "name": "favourited",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taggingStatus": {
+ "name": "taggingStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summarizationStatus": {
+ "name": "summarizationStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarks_userId_idx": {
+ "name": "bookmarks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_createdAt_idx": {
+ "name": "bookmarks_createdAt_idx",
+ "columns": [
+ "createdAt"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_userId_createdAt_id_idx": {
+ "name": "bookmarks_userId_createdAt_id_idx",
+ "columns": [
+ "userId",
+ "createdAt",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_userId_archived_createdAt_id_idx": {
+ "name": "bookmarks_userId_archived_createdAt_id_idx",
+ "columns": [
+ "userId",
+ "archived",
+ "createdAt",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_userId_favourited_createdAt_id_idx": {
+ "name": "bookmarks_userId_favourited_createdAt_id_idx",
+ "columns": [
+ "userId",
+ "favourited",
+ "createdAt",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarks_userId_user_id_fk": {
+ "name": "bookmarks_userId_user_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarksInLists": {
+ "name": "bookmarksInLists",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "listMembershipId": {
+ "name": "listMembershipId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarksInLists_bookmarkId_idx": {
+ "name": "bookmarksInLists_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "bookmarksInLists_listId_idx": {
+ "name": "bookmarksInLists_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ },
+ "bookmarksInLists_listId_bookmarkId_idx": {
+ "name": "bookmarksInLists_listId_bookmarkId_idx",
+ "columns": [
+ "listId",
+ "bookmarkId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarksInLists_bookmarkId_bookmarks_id_fk": {
+ "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarksInLists_listId_bookmarkLists_id_fk": {
+ "name": "bookmarksInLists_listId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarksInLists_listMembershipId_listCollaborators_id_fk": {
+ "name": "bookmarksInLists_listMembershipId_listCollaborators_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "listCollaborators",
+ "columnsFrom": [
+ "listMembershipId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "bookmarksInLists_bookmarkId_listId_pk": {
+ "columns": [
+ "bookmarkId",
+ "listId"
+ ],
+ "name": "bookmarksInLists_bookmarkId_listId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "config": {
+ "name": "config",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "customPrompts": {
+ "name": "customPrompts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appliesTo": {
+ "name": "appliesTo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "customPrompts_userId_idx": {
+ "name": "customPrompts_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "customPrompts_userId_user_id_fk": {
+ "name": "customPrompts_userId_user_id_fk",
+ "tableFrom": "customPrompts",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "highlights": {
+ "name": "highlights",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "startOffset": {
+ "name": "startOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endOffset": {
+ "name": "endOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'yellow'"
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "highlights_bookmarkId_idx": {
+ "name": "highlights_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "highlights_userId_idx": {
+ "name": "highlights_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "highlights_bookmarkId_bookmarks_id_fk": {
+ "name": "highlights_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "highlights_userId_user_id_fk": {
+ "name": "highlights_userId_user_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "importSessionBookmarks": {
+ "name": "importSessionBookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "importSessionId": {
+ "name": "importSessionId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "importSessionBookmarks_sessionId_idx": {
+ "name": "importSessionBookmarks_sessionId_idx",
+ "columns": [
+ "importSessionId"
+ ],
+ "isUnique": false
+ },
+ "importSessionBookmarks_bookmarkId_idx": {
+ "name": "importSessionBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "importSessionBookmarks_importSessionId_bookmarkId_unique": {
+ "name": "importSessionBookmarks_importSessionId_bookmarkId_unique",
+ "columns": [
+ "importSessionId",
+ "bookmarkId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "importSessionBookmarks_importSessionId_importSessions_id_fk": {
+ "name": "importSessionBookmarks_importSessionId_importSessions_id_fk",
+ "tableFrom": "importSessionBookmarks",
+ "tableTo": "importSessions",
+ "columnsFrom": [
+ "importSessionId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "importSessionBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "importSessionBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "importSessionBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "importSessions": {
+ "name": "importSessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rootListId": {
+ "name": "rootListId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'staging'"
+ },
+ "lastProcessedAt": {
+ "name": "lastProcessedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "importSessions_userId_idx": {
+ "name": "importSessions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "importSessions_userId_user_id_fk": {
+ "name": "importSessions_userId_user_id_fk",
+ "tableFrom": "importSessions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "importSessions_rootListId_bookmarkLists_id_fk": {
+ "name": "importSessions_rootListId_bookmarkLists_id_fk",
+ "tableFrom": "importSessions",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "rootListId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "importStagingBookmarks": {
+ "name": "importStagingBookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "importSessionId": {
+ "name": "importSessionId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "listIds": {
+ "name": "listIds",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceAddedAt": {
+ "name": "sourceAddedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "processingStartedAt": {
+ "name": "processingStartedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "result": {
+ "name": "result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resultReason": {
+ "name": "resultReason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resultBookmarkId": {
+ "name": "resultBookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "importStaging_session_status_idx": {
+ "name": "importStaging_session_status_idx",
+ "columns": [
+ "importSessionId",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "importStaging_completedAt_idx": {
+ "name": "importStaging_completedAt_idx",
+ "columns": [
+ "completedAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "importStagingBookmarks_importSessionId_importSessions_id_fk": {
+ "name": "importStagingBookmarks_importSessionId_importSessions_id_fk",
+ "tableFrom": "importStagingBookmarks",
+ "tableTo": "importSessions",
+ "columnsFrom": [
+ "importSessionId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "importStagingBookmarks_resultBookmarkId_bookmarks_id_fk": {
+ "name": "importStagingBookmarks_resultBookmarkId_bookmarks_id_fk",
+ "tableFrom": "importStagingBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "resultBookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invites": {
+ "name": "invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "usedAt": {
+ "name": "usedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invitedBy": {
+ "name": "invitedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invites_token_unique": {
+ "name": "invites_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invites_invitedBy_user_id_fk": {
+ "name": "invites_invitedBy_user_id_fk",
+ "tableFrom": "invites",
+ "tableTo": "user",
+ "columnsFrom": [
+ "invitedBy"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "listCollaborators": {
+ "name": "listCollaborators",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedBy": {
+ "name": "addedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "listCollaborators_listId_idx": {
+ "name": "listCollaborators_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ },
+ "listCollaborators_userId_idx": {
+ "name": "listCollaborators_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "listCollaborators_listId_userId_unique": {
+ "name": "listCollaborators_listId_userId_unique",
+ "columns": [
+ "listId",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "listCollaborators_listId_bookmarkLists_id_fk": {
+ "name": "listCollaborators_listId_bookmarkLists_id_fk",
+ "tableFrom": "listCollaborators",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listCollaborators_userId_user_id_fk": {
+ "name": "listCollaborators_userId_user_id_fk",
+ "tableFrom": "listCollaborators",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listCollaborators_addedBy_user_id_fk": {
+ "name": "listCollaborators_addedBy_user_id_fk",
+ "tableFrom": "listCollaborators",
+ "tableTo": "user",
+ "columnsFrom": [
+ "addedBy"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "listInvitations": {
+ "name": "listInvitations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "invitedAt": {
+ "name": "invitedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedEmail": {
+ "name": "invitedEmail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invitedBy": {
+ "name": "invitedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "listInvitations_listId_idx": {
+ "name": "listInvitations_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ },
+ "listInvitations_userId_idx": {
+ "name": "listInvitations_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "listInvitations_status_idx": {
+ "name": "listInvitations_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "listInvitations_listId_userId_unique": {
+ "name": "listInvitations_listId_userId_unique",
+ "columns": [
+ "listId",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "listInvitations_listId_bookmarkLists_id_fk": {
+ "name": "listInvitations_listId_bookmarkLists_id_fk",
+ "tableFrom": "listInvitations",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listInvitations_userId_user_id_fk": {
+ "name": "listInvitations_userId_user_id_fk",
+ "tableFrom": "listInvitations",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listInvitations_invitedBy_user_id_fk": {
+ "name": "listInvitations_invitedBy_user_id_fk",
+ "tableFrom": "listInvitations",
+ "tableTo": "user",
+ "columnsFrom": [
+ "invitedBy"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "passwordResetToken": {
+ "name": "passwordResetToken",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "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
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "passwordResetToken_token_unique": {
+ "name": "passwordResetToken_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "passwordResetTokens_userId_idx": {
+ "name": "passwordResetTokens_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "passwordResetToken_userId_user_id_fk": {
+ "name": "passwordResetToken_userId_user_id_fk",
+ "tableFrom": "passwordResetToken",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeedImports": {
+ "name": "rssFeedImports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entryId": {
+ "name": "entryId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rssFeedId": {
+ "name": "rssFeedId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeedImports_feedIdIdx_idx": {
+ "name": "rssFeedImports_feedIdIdx_idx",
+ "columns": [
+ "rssFeedId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_entryIdIdx_idx": {
+ "name": "rssFeedImports_entryIdIdx_idx",
+ "columns": [
+ "entryId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_rssFeedId_bookmarkId_idx": {
+ "name": "rssFeedImports_rssFeedId_bookmarkId_idx",
+ "columns": [
+ "rssFeedId",
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_rssFeedId_entryId_unique": {
+ "name": "rssFeedImports_rssFeedId_entryId_unique",
+ "columns": [
+ "rssFeedId",
+ "entryId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rssFeedImports_rssFeedId_rssFeeds_id_fk": {
+ "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "rssFeeds",
+ "columnsFrom": [
+ "rssFeedId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "rssFeedImports_bookmarkId_bookmarks_id_fk": {
+ "name": "rssFeedImports_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeeds": {
+ "name": "rssFeeds",
+ "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
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "importTags": {
+ "name": "importTags",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lastFetchedAt": {
+ "name": "lastFetchedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastFetchedStatus": {
+ "name": "lastFetchedStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeeds_userId_idx": {
+ "name": "rssFeeds_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rssFeeds_userId_user_id_fk": {
+ "name": "rssFeeds_userId_user_id_fk",
+ "tableFrom": "rssFeeds",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineActions": {
+ "name": "ruleEngineActions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ruleId": {
+ "name": "ruleId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngineActions_userId_idx": {
+ "name": "ruleEngineActions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "ruleEngineActions_ruleId_idx": {
+ "name": "ruleEngineActions_ruleId_idx",
+ "columns": [
+ "ruleId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineActions_userId_user_id_fk": {
+ "name": "ruleEngineActions_userId_user_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_ruleId_ruleEngineRules_id_fk": {
+ "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "ruleEngineRules",
+ "columnsFrom": [
+ "ruleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_tagId_fk": {
+ "name": "ruleEngineActions_userId_tagId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_listId_fk": {
+ "name": "ruleEngineActions_userId_listId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineRules": {
+ "name": "ruleEngineRules",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngine_userId_idx": {
+ "name": "ruleEngine_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineRules_userId_user_id_fk": {
+ "name": "ruleEngineRules_userId_user_id_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_tagId_fk": {
+ "name": "ruleEngineRules_userId_tagId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_listId_fk": {
+ "name": "ruleEngineRules_userId_listId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "subscriptions": {
+ "name": "subscriptions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "priceId": {
+ "name": "priceId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelAtPeriodEnd": {
+ "name": "cancelAtPeriodEnd",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "startDate": {
+ "name": "startDate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "endDate": {
+ "name": "endDate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "subscriptions_userId_unique": {
+ "name": "subscriptions_userId_unique",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": true
+ },
+ "subscriptions_userId_idx": {
+ "name": "subscriptions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "subscriptions_stripeCustomerId_idx": {
+ "name": "subscriptions_stripeCustomerId_idx",
+ "columns": [
+ "stripeCustomerId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "subscriptions_userId_user_id_fk": {
+ "name": "subscriptions_userId_user_id_fk",
+ "tableFrom": "subscriptions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tagsOnBookmarks": {
+ "name": "tagsOnBookmarks",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachedAt": {
+ "name": "attachedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attachedBy": {
+ "name": "attachedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tagsOnBookmarks_tagId_idx": {
+ "name": "tagsOnBookmarks_tagId_idx",
+ "columns": [
+ "tagId"
+ ],
+ "isUnique": false
+ },
+ "tagsOnBookmarks_bookmarkId_idx": {
+ "name": "tagsOnBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "tagsOnBookmarks_tagId_bookmarkId_idx": {
+ "name": "tagsOnBookmarks_tagId_bookmarkId_idx",
+ "columns": [
+ "tagId",
+ "bookmarkId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tagsOnBookmarks_tagId_bookmarkTags_id_fk": {
+ "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "tagId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tagsOnBookmarks_bookmarkId_tagId_pk": {
+ "columns": [
+ "bookmarkId",
+ "tagId"
+ ],
+ "name": "tagsOnBookmarks_bookmarkId_tagId_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": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "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
+ },
+ "salt": {
+ "name": "salt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "bookmarkQuota": {
+ "name": "bookmarkQuota",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "storageQuota": {
+ "name": "storageQuota",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "browserCrawlingEnabled": {
+ "name": "browserCrawlingEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bookmarkClickAction": {
+ "name": "bookmarkClickAction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open_original_link'"
+ },
+ "archiveDisplayBehaviour": {
+ "name": "archiveDisplayBehaviour",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'show'"
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'UTC'"
+ },
+ "backupsEnabled": {
+ "name": "backupsEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "backupsFrequency": {
+ "name": "backupsFrequency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'weekly'"
+ },
+ "backupsRetentionDays": {
+ "name": "backupsRetentionDays",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "readerFontSize": {
+ "name": "readerFontSize",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "readerLineHeight": {
+ "name": "readerLineHeight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "readerFontFamily": {
+ "name": "readerFontFamily",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "autoTaggingEnabled": {
+ "name": "autoTaggingEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "autoSummarizationEnabled": {
+ "name": "autoSummarizationEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagStyle": {
+ "name": "tagStyle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'titlecase-spaces'"
+ },
+ "inferredTagLang": {
+ "name": "inferredTagLang",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "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": {}
+ },
+ "webhooks": {
+ "name": "webhooks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "events": {
+ "name": "events",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "webhooks_userId_idx": {
+ "name": "webhooks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "webhooks_userId_user_id_fk": {
+ "name": "webhooks_userId_user_id_fk",
+ "tableFrom": "webhooks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+} \ No newline at end of file
diff --git a/packages/db/drizzle/meta/0078_snapshot.json b/packages/db/drizzle/meta/0078_snapshot.json
new file mode 100644
index 00000000..7d4cda8a
--- /dev/null
+++ b/packages/db/drizzle/meta/0078_snapshot.json
@@ -0,0 +1,3233 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "4606d370-ba98-401d-af38-646707d73764",
+ "prevId": "49a682e5-a215-43da-ac0f-dd1a2ccea538",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "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
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "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": {},
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "columns": [
+ "provider",
+ "providerAccountId"
+ ],
+ "name": "account_provider_providerAccountId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lastUsedAt": {
+ "name": "lastUsedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "keyId": {
+ "name": "keyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apiKey_keyId_unique": {
+ "name": "apiKey_keyId_unique",
+ "columns": [
+ "keyId"
+ ],
+ "isUnique": true
+ },
+ "apiKey_name_userId_unique": {
+ "name": "apiKey_name_userId_unique",
+ "columns": [
+ "name",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "apiKey_userId_user_id_fk": {
+ "name": "apiKey_userId_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "assets": {
+ "name": "assets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "assets_bookmarkId_idx": {
+ "name": "assets_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "assets_assetType_idx": {
+ "name": "assets_assetType_idx",
+ "columns": [
+ "assetType"
+ ],
+ "isUnique": false
+ },
+ "assets_userId_idx": {
+ "name": "assets_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assets_bookmarkId_bookmarks_id_fk": {
+ "name": "assets_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "assets_userId_user_id_fk": {
+ "name": "assets_userId_user_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "backups": {
+ "name": "backups",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetId": {
+ "name": "assetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkCount": {
+ "name": "bookmarkCount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "errorMessage": {
+ "name": "errorMessage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "backups_userId_idx": {
+ "name": "backups_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "backups_createdAt_idx": {
+ "name": "backups_createdAt_idx",
+ "columns": [
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "backups_userId_user_id_fk": {
+ "name": "backups_userId_user_id_fk",
+ "tableFrom": "backups",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "backups_assetId_assets_id_fk": {
+ "name": "backups_assetId_assets_id_fk",
+ "tableFrom": "backups",
+ "tableTo": "assets",
+ "columnsFrom": [
+ "assetId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkAssets": {
+ "name": "bookmarkAssets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetId": {
+ "name": "assetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkAssets_id_bookmarks_id_fk": {
+ "name": "bookmarkAssets_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkAssets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLinks": {
+ "name": "bookmarkLinks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "author": {
+ "name": "author",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "publisher": {
+ "name": "publisher",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "datePublished": {
+ "name": "datePublished",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dateModified": {
+ "name": "dateModified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imageUrl": {
+ "name": "imageUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon": {
+ "name": "favicon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "htmlContent": {
+ "name": "htmlContent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contentAssetId": {
+ "name": "contentAssetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawledAt": {
+ "name": "crawledAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawlStatus": {
+ "name": "crawlStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "crawlStatusCode": {
+ "name": "crawlStatusCode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 200
+ }
+ },
+ "indexes": {
+ "bookmarkLinks_url_idx": {
+ "name": "bookmarkLinks_url_idx",
+ "columns": [
+ "url"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLinks_id_bookmarks_id_fk": {
+ "name": "bookmarkLinks_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkLinks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLists": {
+ "name": "bookmarkLists",
+ "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": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "query": {
+ "name": "query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "parentId": {
+ "name": "parentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rssToken": {
+ "name": "rssToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public": {
+ "name": "public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "bookmarkLists_userId_idx": {
+ "name": "bookmarkLists_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkLists_userId_id_idx": {
+ "name": "bookmarkLists_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLists_userId_user_id_fk": {
+ "name": "bookmarkLists_userId_user_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarkLists_parentId_bookmarkLists_id_fk": {
+ "name": "bookmarkLists_parentId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "parentId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTags": {
+ "name": "bookmarkTags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "normalizedName": {
+ "name": "normalizedName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "generated": {
+ "as": "(lower(replace(replace(replace(\"name\", ' ', ''), '-', ''), '_', '')))",
+ "type": "virtual"
+ }
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarkTags_name_idx": {
+ "name": "bookmarkTags_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_idx": {
+ "name": "bookmarkTags_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_normalizedName_idx": {
+ "name": "bookmarkTags_normalizedName_idx",
+ "columns": [
+ "normalizedName"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_name_unique": {
+ "name": "bookmarkTags_userId_name_unique",
+ "columns": [
+ "userId",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "bookmarkTags_userId_id_idx": {
+ "name": "bookmarkTags_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkTags_userId_user_id_fk": {
+ "name": "bookmarkTags_userId_user_id_fk",
+ "tableFrom": "bookmarkTags",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTexts": {
+ "name": "bookmarkTexts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkTexts_id_bookmarks_id_fk": {
+ "name": "bookmarkTexts_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkTexts",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarks": {
+ "name": "bookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived": {
+ "name": "archived",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "favourited": {
+ "name": "favourited",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taggingStatus": {
+ "name": "taggingStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summarizationStatus": {
+ "name": "summarizationStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarks_userId_idx": {
+ "name": "bookmarks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_createdAt_idx": {
+ "name": "bookmarks_createdAt_idx",
+ "columns": [
+ "createdAt"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_userId_createdAt_id_idx": {
+ "name": "bookmarks_userId_createdAt_id_idx",
+ "columns": [
+ "userId",
+ "createdAt",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_userId_archived_createdAt_id_idx": {
+ "name": "bookmarks_userId_archived_createdAt_id_idx",
+ "columns": [
+ "userId",
+ "archived",
+ "createdAt",
+ "id"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_userId_favourited_createdAt_id_idx": {
+ "name": "bookmarks_userId_favourited_createdAt_id_idx",
+ "columns": [
+ "userId",
+ "favourited",
+ "createdAt",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarks_userId_user_id_fk": {
+ "name": "bookmarks_userId_user_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarksInLists": {
+ "name": "bookmarksInLists",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "listMembershipId": {
+ "name": "listMembershipId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarksInLists_bookmarkId_idx": {
+ "name": "bookmarksInLists_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "bookmarksInLists_listId_idx": {
+ "name": "bookmarksInLists_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ },
+ "bookmarksInLists_listId_bookmarkId_idx": {
+ "name": "bookmarksInLists_listId_bookmarkId_idx",
+ "columns": [
+ "listId",
+ "bookmarkId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarksInLists_bookmarkId_bookmarks_id_fk": {
+ "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarksInLists_listId_bookmarkLists_id_fk": {
+ "name": "bookmarksInLists_listId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarksInLists_listMembershipId_listCollaborators_id_fk": {
+ "name": "bookmarksInLists_listMembershipId_listCollaborators_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "listCollaborators",
+ "columnsFrom": [
+ "listMembershipId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "bookmarksInLists_bookmarkId_listId_pk": {
+ "columns": [
+ "bookmarkId",
+ "listId"
+ ],
+ "name": "bookmarksInLists_bookmarkId_listId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "config": {
+ "name": "config",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "customPrompts": {
+ "name": "customPrompts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appliesTo": {
+ "name": "appliesTo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "customPrompts_userId_idx": {
+ "name": "customPrompts_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "customPrompts_userId_user_id_fk": {
+ "name": "customPrompts_userId_user_id_fk",
+ "tableFrom": "customPrompts",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "highlights": {
+ "name": "highlights",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "startOffset": {
+ "name": "startOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endOffset": {
+ "name": "endOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'yellow'"
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "highlights_bookmarkId_idx": {
+ "name": "highlights_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "highlights_userId_idx": {
+ "name": "highlights_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "highlights_bookmarkId_bookmarks_id_fk": {
+ "name": "highlights_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "highlights_userId_user_id_fk": {
+ "name": "highlights_userId_user_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "importSessionBookmarks": {
+ "name": "importSessionBookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "importSessionId": {
+ "name": "importSessionId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "importSessionBookmarks_sessionId_idx": {
+ "name": "importSessionBookmarks_sessionId_idx",
+ "columns": [
+ "importSessionId"
+ ],
+ "isUnique": false
+ },
+ "importSessionBookmarks_bookmarkId_idx": {
+ "name": "importSessionBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "importSessionBookmarks_importSessionId_bookmarkId_unique": {
+ "name": "importSessionBookmarks_importSessionId_bookmarkId_unique",
+ "columns": [
+ "importSessionId",
+ "bookmarkId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "importSessionBookmarks_importSessionId_importSessions_id_fk": {
+ "name": "importSessionBookmarks_importSessionId_importSessions_id_fk",
+ "tableFrom": "importSessionBookmarks",
+ "tableTo": "importSessions",
+ "columnsFrom": [
+ "importSessionId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "importSessionBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "importSessionBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "importSessionBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "importSessions": {
+ "name": "importSessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rootListId": {
+ "name": "rootListId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'staging'"
+ },
+ "lastProcessedAt": {
+ "name": "lastProcessedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "importSessions_userId_idx": {
+ "name": "importSessions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "importSessions_status_idx": {
+ "name": "importSessions_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "importSessions_userId_user_id_fk": {
+ "name": "importSessions_userId_user_id_fk",
+ "tableFrom": "importSessions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "importSessions_rootListId_bookmarkLists_id_fk": {
+ "name": "importSessions_rootListId_bookmarkLists_id_fk",
+ "tableFrom": "importSessions",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "rootListId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "importStagingBookmarks": {
+ "name": "importStagingBookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "importSessionId": {
+ "name": "importSessionId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "listIds": {
+ "name": "listIds",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceAddedAt": {
+ "name": "sourceAddedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "processingStartedAt": {
+ "name": "processingStartedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "result": {
+ "name": "result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resultReason": {
+ "name": "resultReason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "resultBookmarkId": {
+ "name": "resultBookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "completedAt": {
+ "name": "completedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "importStaging_session_status_idx": {
+ "name": "importStaging_session_status_idx",
+ "columns": [
+ "importSessionId",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "importStaging_completedAt_idx": {
+ "name": "importStaging_completedAt_idx",
+ "columns": [
+ "completedAt"
+ ],
+ "isUnique": false
+ },
+ "importStaging_status_idx": {
+ "name": "importStaging_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "importStaging_status_processingStartedAt_idx": {
+ "name": "importStaging_status_processingStartedAt_idx",
+ "columns": [
+ "status",
+ "processingStartedAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "importStagingBookmarks_importSessionId_importSessions_id_fk": {
+ "name": "importStagingBookmarks_importSessionId_importSessions_id_fk",
+ "tableFrom": "importStagingBookmarks",
+ "tableTo": "importSessions",
+ "columnsFrom": [
+ "importSessionId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "importStagingBookmarks_resultBookmarkId_bookmarks_id_fk": {
+ "name": "importStagingBookmarks_resultBookmarkId_bookmarks_id_fk",
+ "tableFrom": "importStagingBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "resultBookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invites": {
+ "name": "invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "usedAt": {
+ "name": "usedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invitedBy": {
+ "name": "invitedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invites_token_unique": {
+ "name": "invites_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invites_invitedBy_user_id_fk": {
+ "name": "invites_invitedBy_user_id_fk",
+ "tableFrom": "invites",
+ "tableTo": "user",
+ "columnsFrom": [
+ "invitedBy"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "listCollaborators": {
+ "name": "listCollaborators",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedBy": {
+ "name": "addedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "listCollaborators_listId_idx": {
+ "name": "listCollaborators_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ },
+ "listCollaborators_userId_idx": {
+ "name": "listCollaborators_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "listCollaborators_listId_userId_unique": {
+ "name": "listCollaborators_listId_userId_unique",
+ "columns": [
+ "listId",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "listCollaborators_listId_bookmarkLists_id_fk": {
+ "name": "listCollaborators_listId_bookmarkLists_id_fk",
+ "tableFrom": "listCollaborators",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listCollaborators_userId_user_id_fk": {
+ "name": "listCollaborators_userId_user_id_fk",
+ "tableFrom": "listCollaborators",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listCollaborators_addedBy_user_id_fk": {
+ "name": "listCollaborators_addedBy_user_id_fk",
+ "tableFrom": "listCollaborators",
+ "tableTo": "user",
+ "columnsFrom": [
+ "addedBy"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "listInvitations": {
+ "name": "listInvitations",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "invitedAt": {
+ "name": "invitedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invitedEmail": {
+ "name": "invitedEmail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invitedBy": {
+ "name": "invitedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "listInvitations_listId_idx": {
+ "name": "listInvitations_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ },
+ "listInvitations_userId_idx": {
+ "name": "listInvitations_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "listInvitations_status_idx": {
+ "name": "listInvitations_status_idx",
+ "columns": [
+ "status"
+ ],
+ "isUnique": false
+ },
+ "listInvitations_listId_userId_unique": {
+ "name": "listInvitations_listId_userId_unique",
+ "columns": [
+ "listId",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "listInvitations_listId_bookmarkLists_id_fk": {
+ "name": "listInvitations_listId_bookmarkLists_id_fk",
+ "tableFrom": "listInvitations",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listInvitations_userId_user_id_fk": {
+ "name": "listInvitations_userId_user_id_fk",
+ "tableFrom": "listInvitations",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listInvitations_invitedBy_user_id_fk": {
+ "name": "listInvitations_invitedBy_user_id_fk",
+ "tableFrom": "listInvitations",
+ "tableTo": "user",
+ "columnsFrom": [
+ "invitedBy"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "passwordResetToken": {
+ "name": "passwordResetToken",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "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
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "passwordResetToken_token_unique": {
+ "name": "passwordResetToken_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "passwordResetTokens_userId_idx": {
+ "name": "passwordResetTokens_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "passwordResetToken_userId_user_id_fk": {
+ "name": "passwordResetToken_userId_user_id_fk",
+ "tableFrom": "passwordResetToken",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeedImports": {
+ "name": "rssFeedImports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entryId": {
+ "name": "entryId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rssFeedId": {
+ "name": "rssFeedId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeedImports_feedIdIdx_idx": {
+ "name": "rssFeedImports_feedIdIdx_idx",
+ "columns": [
+ "rssFeedId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_entryIdIdx_idx": {
+ "name": "rssFeedImports_entryIdIdx_idx",
+ "columns": [
+ "entryId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_rssFeedId_bookmarkId_idx": {
+ "name": "rssFeedImports_rssFeedId_bookmarkId_idx",
+ "columns": [
+ "rssFeedId",
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_rssFeedId_entryId_unique": {
+ "name": "rssFeedImports_rssFeedId_entryId_unique",
+ "columns": [
+ "rssFeedId",
+ "entryId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rssFeedImports_rssFeedId_rssFeeds_id_fk": {
+ "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "rssFeeds",
+ "columnsFrom": [
+ "rssFeedId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "rssFeedImports_bookmarkId_bookmarks_id_fk": {
+ "name": "rssFeedImports_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeeds": {
+ "name": "rssFeeds",
+ "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
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "importTags": {
+ "name": "importTags",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lastFetchedAt": {
+ "name": "lastFetchedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastFetchedStatus": {
+ "name": "lastFetchedStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeeds_userId_idx": {
+ "name": "rssFeeds_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rssFeeds_userId_user_id_fk": {
+ "name": "rssFeeds_userId_user_id_fk",
+ "tableFrom": "rssFeeds",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineActions": {
+ "name": "ruleEngineActions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ruleId": {
+ "name": "ruleId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngineActions_userId_idx": {
+ "name": "ruleEngineActions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "ruleEngineActions_ruleId_idx": {
+ "name": "ruleEngineActions_ruleId_idx",
+ "columns": [
+ "ruleId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineActions_userId_user_id_fk": {
+ "name": "ruleEngineActions_userId_user_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_ruleId_ruleEngineRules_id_fk": {
+ "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "ruleEngineRules",
+ "columnsFrom": [
+ "ruleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_tagId_fk": {
+ "name": "ruleEngineActions_userId_tagId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_listId_fk": {
+ "name": "ruleEngineActions_userId_listId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineRules": {
+ "name": "ruleEngineRules",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngine_userId_idx": {
+ "name": "ruleEngine_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineRules_userId_user_id_fk": {
+ "name": "ruleEngineRules_userId_user_id_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_tagId_fk": {
+ "name": "ruleEngineRules_userId_tagId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_listId_fk": {
+ "name": "ruleEngineRules_userId_listId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "subscriptions": {
+ "name": "subscriptions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "priceId": {
+ "name": "priceId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelAtPeriodEnd": {
+ "name": "cancelAtPeriodEnd",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "startDate": {
+ "name": "startDate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "endDate": {
+ "name": "endDate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "subscriptions_userId_unique": {
+ "name": "subscriptions_userId_unique",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": true
+ },
+ "subscriptions_userId_idx": {
+ "name": "subscriptions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "subscriptions_stripeCustomerId_idx": {
+ "name": "subscriptions_stripeCustomerId_idx",
+ "columns": [
+ "stripeCustomerId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "subscriptions_userId_user_id_fk": {
+ "name": "subscriptions_userId_user_id_fk",
+ "tableFrom": "subscriptions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tagsOnBookmarks": {
+ "name": "tagsOnBookmarks",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachedAt": {
+ "name": "attachedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attachedBy": {
+ "name": "attachedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tagsOnBookmarks_tagId_idx": {
+ "name": "tagsOnBookmarks_tagId_idx",
+ "columns": [
+ "tagId"
+ ],
+ "isUnique": false
+ },
+ "tagsOnBookmarks_bookmarkId_idx": {
+ "name": "tagsOnBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "tagsOnBookmarks_tagId_bookmarkId_idx": {
+ "name": "tagsOnBookmarks_tagId_bookmarkId_idx",
+ "columns": [
+ "tagId",
+ "bookmarkId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tagsOnBookmarks_tagId_bookmarkTags_id_fk": {
+ "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "tagId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tagsOnBookmarks_bookmarkId_tagId_pk": {
+ "columns": [
+ "bookmarkId",
+ "tagId"
+ ],
+ "name": "tagsOnBookmarks_bookmarkId_tagId_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": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "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
+ },
+ "salt": {
+ "name": "salt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "bookmarkQuota": {
+ "name": "bookmarkQuota",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "storageQuota": {
+ "name": "storageQuota",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "browserCrawlingEnabled": {
+ "name": "browserCrawlingEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bookmarkClickAction": {
+ "name": "bookmarkClickAction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open_original_link'"
+ },
+ "archiveDisplayBehaviour": {
+ "name": "archiveDisplayBehaviour",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'show'"
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'UTC'"
+ },
+ "backupsEnabled": {
+ "name": "backupsEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "backupsFrequency": {
+ "name": "backupsFrequency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'weekly'"
+ },
+ "backupsRetentionDays": {
+ "name": "backupsRetentionDays",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 30
+ },
+ "readerFontSize": {
+ "name": "readerFontSize",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "readerLineHeight": {
+ "name": "readerLineHeight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "readerFontFamily": {
+ "name": "readerFontFamily",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "autoTaggingEnabled": {
+ "name": "autoTaggingEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "autoSummarizationEnabled": {
+ "name": "autoSummarizationEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagStyle": {
+ "name": "tagStyle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'titlecase-spaces'"
+ },
+ "inferredTagLang": {
+ "name": "inferredTagLang",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "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": {}
+ },
+ "webhooks": {
+ "name": "webhooks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "events": {
+ "name": "events",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "webhooks_userId_idx": {
+ "name": "webhooks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "webhooks_userId_user_id_fk": {
+ "name": "webhooks_userId_user_id_fk",
+ "tableFrom": "webhooks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+} \ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index c7c13878..a3b10496 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -540,6 +540,20 @@
"when": 1768691440519,
"tag": "0076_add_api_key_last_used_tracking",
"breakpoints": true
+ },
+ {
+ "idx": 77,
+ "version": "6",
+ "when": 1770141423845,
+ "tag": "0077_import_listpaths_to_listids",
+ "breakpoints": true
+ },
+ {
+ "idx": 78,
+ "version": "6",
+ "when": 1770142086939,
+ "tag": "0078_add_import_session_indexes",
+ "breakpoints": true
}
]
} \ No newline at end of file
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 8ed91e28..217456c4 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -833,10 +833,19 @@ export const importSessions = sqliteTable(
rootListId: text("rootListId").references(() => bookmarkLists.id, {
onDelete: "set null",
}),
+ status: text("status", {
+ enum: ["staging", "pending", "running", "paused", "completed", "failed"],
+ })
+ .notNull()
+ .default("staging"),
+ lastProcessedAt: integer("lastProcessedAt", { mode: "timestamp" }),
createdAt: createdAtField(),
modifiedAt: modifiedAtField(),
},
- (is) => [index("importSessions_userId_idx").on(is.userId)],
+ (is) => [
+ index("importSessions_userId_idx").on(is.userId),
+ index("importSessions_status_idx").on(is.status),
+ ],
);
export const importSessionBookmarks = sqliteTable(
@@ -861,6 +870,63 @@ export const importSessionBookmarks = sqliteTable(
],
);
+export const importStagingBookmarks = sqliteTable(
+ "importStagingBookmarks",
+ {
+ id: text("id")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => createId()),
+ importSessionId: text("importSessionId")
+ .notNull()
+ .references(() => importSessions.id, { onDelete: "cascade" }),
+
+ // Bookmark data to create
+ type: text("type", { enum: ["link", "text", "asset"] }).notNull(),
+ url: text("url"),
+ title: text("title"),
+ content: text("content"),
+ note: text("note"),
+ tags: text("tags", { mode: "json" }).$type<string[]>(),
+ listIds: text("listIds", { mode: "json" }).$type<string[]>(),
+ sourceAddedAt: integer("sourceAddedAt", { mode: "timestamp" }),
+
+ // Processing state
+ status: text("status", {
+ enum: ["pending", "processing", "completed", "failed"],
+ })
+ .notNull()
+ .default("pending"),
+ processingStartedAt: integer("processingStartedAt", {
+ mode: "timestamp",
+ }),
+
+ // Result (for observability)
+ result: text("result", {
+ enum: ["accepted", "rejected", "skipped_duplicate"],
+ }),
+ resultReason: text("resultReason"),
+ resultBookmarkId: text("resultBookmarkId").references(() => bookmarks.id, {
+ onDelete: "set null",
+ }),
+
+ createdAt: createdAtField(),
+ completedAt: integer("completedAt", { mode: "timestamp" }),
+ },
+ (isb) => [
+ index("importStaging_session_status_idx").on(
+ isb.importSessionId,
+ isb.status,
+ ),
+ index("importStaging_completedAt_idx").on(isb.completedAt),
+ index("importStaging_status_idx").on(isb.status),
+ index("importStaging_status_processingStartedAt_idx").on(
+ isb.status,
+ isb.processingStartedAt,
+ ),
+ ],
+);
+
// Relations
export const userRelations = relations(users, ({ many, one }) => ({
diff --git a/packages/e2e_tests/tests/workers/import.test.ts b/packages/e2e_tests/tests/workers/import.test.ts
new file mode 100644
index 00000000..e40200f2
--- /dev/null
+++ b/packages/e2e_tests/tests/workers/import.test.ts
@@ -0,0 +1,324 @@
+import { assert, beforeEach, describe, expect, inject, it } from "vitest";
+
+import { createKarakeepClient } from "@karakeep/sdk";
+
+import { createTestUser } from "../../utils/api";
+import { waitUntil } from "../../utils/general";
+import { getTrpcClient } from "../../utils/trpc";
+
+describe("Import Worker Tests", () => {
+ const port = inject("karakeepPort");
+
+ if (!port) {
+ throw new Error("Missing required environment variables");
+ }
+
+ let client: ReturnType<typeof createKarakeepClient>;
+ let trpc: ReturnType<typeof getTrpcClient>;
+ let apiKey: string;
+
+ beforeEach(async () => {
+ apiKey = await createTestUser();
+ client = createKarakeepClient({
+ baseUrl: `http://localhost:${port}/api/v1/`,
+ headers: {
+ "Content-Type": "application/json",
+ authorization: `Bearer ${apiKey}`,
+ },
+ });
+ trpc = getTrpcClient(apiKey);
+ });
+
+ it("should import 15 bookmarks of different types", async () => {
+ // Create lists first (lists require IDs, not paths)
+ const { data: parentList } = await client.POST("/lists", {
+ body: {
+ name: "Import Test List",
+ icon: "folder",
+ },
+ });
+ assert(parentList);
+
+ const { data: nestedList } = await client.POST("/lists", {
+ body: {
+ name: "Nested",
+ icon: "folder",
+ parentId: parentList.id,
+ },
+ });
+ assert(nestedList);
+
+ // Create a root list that all imported bookmarks will be attached to
+ const { data: rootList } = await client.POST("/lists", {
+ body: {
+ name: "Import Root List",
+ icon: "folder",
+ },
+ });
+ assert(rootList);
+
+ // Create an import session with rootListId
+ const { id: importSessionId } =
+ await trpc.importSessions.createImportSession.mutate({
+ name: "E2E Test Import",
+ rootListId: rootList.id,
+ });
+ assert(importSessionId);
+
+ // Define 15 bookmarks of different types
+ const bookmarksToImport = [
+ // Links (7 total, with varying metadata)
+ {
+ type: "link" as const,
+ url: "http://nginx:80/hello.html",
+ },
+ {
+ type: "link" as const,
+ url: "http://nginx:80/page1.html",
+ title: "Page 1 Title",
+ },
+ {
+ type: "link" as const,
+ url: "http://nginx:80/page2.html",
+ title: "Page 2 with Note",
+ note: "This is a note for page 2",
+ },
+ {
+ type: "link" as const,
+ url: "http://nginx:80/page3.html",
+ title: "Page 3 with Tags",
+ tags: ["tag1", "tag2"],
+ },
+ {
+ type: "link" as const,
+ url: "http://nginx:80/page4.html",
+ title: "Page 4 with List",
+ listIds: [parentList.id],
+ },
+ {
+ type: "link" as const,
+ url: "http://nginx:80/page5.html",
+ title: "Page 5 with Source Date",
+ sourceAddedAt: new Date("2024-01-15T10:30:00Z"),
+ },
+ {
+ type: "link" as const,
+ url: "http://nginx:80/page6.html",
+ title: "Page 6 Full Metadata",
+ note: "Full metadata note",
+ tags: ["imported", "full"],
+ listIds: [nestedList.id],
+ sourceAddedAt: new Date("2024-02-20T15:45:00Z"),
+ },
+
+ // Text bookmarks (5 total)
+ {
+ type: "text" as const,
+ content: "This is a basic text bookmark content.",
+ },
+ {
+ type: "text" as const,
+ title: "Text with Title",
+ content: "Text bookmark with a title.",
+ },
+ {
+ type: "text" as const,
+ title: "Text with Tags",
+ content: "Text bookmark with tags attached.",
+ tags: ["text-tag", "imported"],
+ },
+ {
+ type: "text" as const,
+ title: "Text with Note",
+ content: "Text bookmark content here.",
+ note: "A note attached to this text bookmark.",
+ },
+ {
+ type: "text" as const,
+ title: "Text Full Metadata",
+ content: "Text bookmark with all metadata fields.",
+ note: "Complete text note",
+ tags: ["complete", "text"],
+ listIds: [parentList.id],
+ sourceAddedAt: new Date("2024-03-10T08:00:00Z"),
+ },
+
+ // Duplicates (3 total - same URLs as earlier links)
+ {
+ type: "link" as const,
+ url: "http://nginx:80/hello.html", // Duplicate of link #1
+ title: "Duplicate of Hello",
+ },
+ {
+ type: "link" as const,
+ url: "http://nginx:80/page1.html", // Duplicate of link #2
+ title: "Duplicate of Page 1",
+ },
+ {
+ type: "link" as const,
+ url: "http://nginx:80/page2.html", // Duplicate of link #3
+ title: "Duplicate of Page 2",
+ note: "Different note but same URL",
+ },
+ ];
+
+ // Stage all bookmarks
+ await trpc.importSessions.stageImportedBookmarks.mutate({
+ importSessionId,
+ bookmarks: bookmarksToImport,
+ });
+
+ // Finalize the import to trigger processing
+ await trpc.importSessions.finalizeImportStaging.mutate({
+ importSessionId,
+ });
+
+ // Wait for all bookmarks to be processed
+ await waitUntil(
+ async () => {
+ const stats = await trpc.importSessions.getImportSessionStats.query({
+ importSessionId,
+ });
+ const allProcessed =
+ stats.completedBookmarks + stats.failedBookmarks ===
+ stats.totalBookmarks;
+ console.log(
+ `Import progress: ${stats.completedBookmarks} completed, ${stats.failedBookmarks} failed, ${stats.totalBookmarks} total`,
+ );
+ return allProcessed && stats.totalBookmarks === 15;
+ },
+ "All bookmarks are processed",
+ 120000, // 2 minutes timeout
+ );
+
+ // Get final stats
+ const finalStats = await trpc.importSessions.getImportSessionStats.query({
+ importSessionId,
+ });
+
+ expect(finalStats.totalBookmarks).toBe(15);
+ expect(finalStats.completedBookmarks).toBe(15);
+ expect(finalStats.failedBookmarks).toBe(0);
+ expect(finalStats.status).toBe("completed");
+
+ // Get results by filter
+ const acceptedResults =
+ await trpc.importSessions.getImportSessionResults.query({
+ importSessionId,
+ filter: "accepted",
+ limit: 50,
+ });
+
+ const duplicateResults =
+ await trpc.importSessions.getImportSessionResults.query({
+ importSessionId,
+ filter: "skipped_duplicate",
+ limit: 50,
+ });
+
+ // We expect 12 accepted (7 links + 5 text) and 3 duplicates
+ expect(acceptedResults.items.length).toBe(12);
+ expect(duplicateResults.items.length).toBe(3);
+
+ // Verify duplicates reference the original bookmarks
+ for (const dup of duplicateResults.items) {
+ expect(dup.resultBookmarkId).toBeDefined();
+ expect(dup.result).toBe("skipped_duplicate");
+ }
+
+ // Verify accepted bookmarks have resultBookmarkId
+ for (const accepted of acceptedResults.items) {
+ expect(accepted.resultBookmarkId).toBeDefined();
+ expect(accepted.result).toBe("accepted");
+ }
+
+ // Verify actual bookmarks were created via the API
+ const { data: allBookmarks } = await client.GET("/bookmarks", {
+ params: {
+ query: {
+ limit: 50,
+ },
+ },
+ });
+ assert(allBookmarks);
+
+ // Should have 12 unique bookmarks (7 links + 5 text)
+ expect(allBookmarks.bookmarks.length).toBe(12);
+
+ // Verify link bookmarks
+ const linkBookmarks = allBookmarks.bookmarks.filter(
+ (b) => b.content.type === "link",
+ );
+ expect(linkBookmarks.length).toBe(7);
+
+ // Verify text bookmarks
+ const textBookmarks = allBookmarks.bookmarks.filter(
+ (b) => b.content.type === "text",
+ );
+ expect(textBookmarks.length).toBe(5);
+
+ // Verify tags were created
+ const { data: tagsResponse } = await client.GET("/tags", {});
+ assert(tagsResponse);
+ const tagNames = tagsResponse.tags.map((t) => t.name);
+ expect(tagNames).toContain("tag1");
+ expect(tagNames).toContain("tag2");
+ expect(tagNames).toContain("imported");
+ expect(tagNames).toContain("text-tag");
+ expect(tagNames).toContain("complete");
+
+ // Verify tags are actually attached to bookmarks
+ // Find a bookmark with tags and verify
+ const bookmarkWithTags = allBookmarks.bookmarks.find((b) =>
+ b.tags.some((t) => t.name === "tag1"),
+ );
+ assert(bookmarkWithTags, "Should find a bookmark with tag1");
+ expect(bookmarkWithTags.tags.map((t) => t.name)).toContain("tag1");
+ expect(bookmarkWithTags.tags.map((t) => t.name)).toContain("tag2");
+
+ // Verify "imported" tag is on multiple bookmarks (used in link and text)
+ const bookmarksWithImportedTag = allBookmarks.bookmarks.filter((b) =>
+ b.tags.some((t) => t.name === "imported"),
+ );
+ expect(bookmarksWithImportedTag.length).toBeGreaterThanOrEqual(2);
+
+ // Verify bookmarks are actually in the lists
+ const { data: parentListBookmarks } = await client.GET(
+ "/lists/{listId}/bookmarks",
+ {
+ params: {
+ path: { listId: parentList.id },
+ },
+ },
+ );
+ assert(parentListBookmarks);
+ // Should have bookmarks with listIds containing parentList.id
+ expect(parentListBookmarks.bookmarks.length).toBeGreaterThanOrEqual(2);
+
+ // Verify nested list has bookmarks
+ const { data: nestedListBookmarks } = await client.GET(
+ "/lists/{listId}/bookmarks",
+ {
+ params: {
+ path: { listId: nestedList.id },
+ },
+ },
+ );
+ assert(nestedListBookmarks);
+ // Should have the bookmark with listIds containing nestedList.id
+ expect(nestedListBookmarks.bookmarks.length).toBeGreaterThanOrEqual(1);
+
+ // Verify ALL imported bookmarks are in the root list (via rootListId)
+ const { data: rootListBookmarks } = await client.GET(
+ "/lists/{listId}/bookmarks",
+ {
+ params: {
+ path: { listId: rootList.id },
+ },
+ },
+ );
+ assert(rootListBookmarks);
+ // All 12 unique bookmarks should be in the root list
+ expect(rootListBookmarks.bookmarks.length).toBe(12);
+ });
+});
diff --git a/packages/shared/import-export/importer.test.ts b/packages/shared/import-export/importer.test.ts
index 7e381f20..f097f8d5 100644
--- a/packages/shared/import-export/importer.test.ts
+++ b/packages/shared/import-export/importer.test.ts
@@ -1,13 +1,14 @@
import { describe, expect, it, vi } from "vitest";
-import { importBookmarksFromFile, ParsedBookmark } from ".";
+import type { StagedBookmark } from ".";
+import { importBookmarksFromFile } from ".";
const fakeFile = {
text: vi.fn().mockResolvedValue("fake file content"),
} as unknown as File;
describe("importBookmarksFromFile", () => {
- it("creates root list, folders and imports bookmarks with progress", async () => {
+ it("creates root list, folders and stages bookmarks with progress", async () => {
const parsers = {
pocket: vi.fn().mockReturnValue([
{
@@ -61,32 +62,23 @@ describe("importBookmarksFromFile", () => {
},
);
- const createdBookmarks: ParsedBookmark[] = [];
- const addedToLists: { bookmarkId: string; listIds: string[] }[] = [];
- const updatedTags: { bookmarkId: string; tags: string[] }[] = [];
-
- const createBookmark = vi.fn(async (bookmark: ParsedBookmark) => {
- createdBookmarks.push(bookmark);
- return {
- id: `bookmark-${createdBookmarks.length}`,
- alreadyExists: false,
- };
- });
-
- const addBookmarkToLists = vi.fn(
- async (input: { bookmarkId: string; listIds: string[] }) => {
- addedToLists.push(input);
+ const stagedBookmarks: StagedBookmark[] = [];
+ const stageImportedBookmarks = vi.fn(
+ async (input: {
+ importSessionId: string;
+ bookmarks: StagedBookmark[];
+ }) => {
+ stagedBookmarks.push(...input.bookmarks);
},
);
- const updateBookmarkTags = vi.fn(
- async (input: { bookmarkId: string; tags: string[] }) => {
- updatedTags.push(input);
- },
+ const finalizeImportStaging = vi.fn();
+ const createImportSession = vi.fn(
+ async (_input: { name: string; rootListId: string }) => ({
+ id: "session-1",
+ }),
);
- const createImportSession = vi.fn(async () => ({ id: "session-1" }));
-
const progress: number[] = [];
const res = await importBookmarksFromFile(
{
@@ -95,9 +87,8 @@ describe("importBookmarksFromFile", () => {
rootListName: "Imported",
deps: {
createList,
- createBookmark,
- addBookmarkToLists,
- updateBookmarkTags,
+ stageImportedBookmarks,
+ finalizeImportStaging,
createImportSession,
},
onProgress: (d, t) => progress.push(d / t),
@@ -106,12 +97,14 @@ describe("importBookmarksFromFile", () => {
);
expect(res.rootListId).toBe("Imported");
+ expect(res.importSessionId).toBe("session-1");
expect(res.counts).toEqual({
- successes: 5,
+ successes: 0,
failures: 0,
alreadyExisted: 0,
total: 5, // Using custom parser, no deduplication
});
+
// Root + all unique folders from paths
expect(createdLists).toEqual([
{ name: "Imported", icon: "⬆️" },
@@ -122,38 +115,43 @@ describe("importBookmarksFromFile", () => {
{ name: "Tech", parentId: "Imported/Reading", icon: "📁" },
{ name: "Duplicates", parentId: "Imported/Development", icon: "📁" },
]);
- // Verify we have 5 created bookmarks (no deduplication with custom parser)
- expect(createdBookmarks).toHaveLength(5);
- // Verify GitHub bookmark exists (will be two separate bookmarks since no deduplication)
- const githubBookmarks = createdBookmarks.filter(
- (bookmark) =>
- bookmark.content?.type === "link" &&
- bookmark.content.url === "https://github.com/example/repo",
- );
- expect(githubBookmarks).toHaveLength(2);
- // Verify text bookmark exists
- const textBookmark = createdBookmarks.find(
- (bookmark) => bookmark.content?.type === "text",
+
+ // Verify 5 bookmarks were staged (in 1 batch since < 50)
+ expect(stagedBookmarks).toHaveLength(5);
+ expect(stageImportedBookmarks).toHaveBeenCalledTimes(1);
+
+ // Verify GitHub link bookmark was staged correctly
+ const githubBookmark = stagedBookmarks.find(
+ (b) => b.url === "https://github.com/example/repo" && b.type === "link",
);
+ expect(githubBookmark).toBeDefined();
+ if (!githubBookmark) {
+ throw new Error("Expected GitHub bookmark to be staged");
+ }
+ expect(githubBookmark.title).toBe("GitHub Repository");
+ expect(githubBookmark.tags).toEqual(["dev", "github"]);
+ expect(githubBookmark.listIds).toEqual(["Imported/Development/Projects"]);
+
+ // Verify text bookmark was staged correctly
+ const textBookmark = stagedBookmarks.find((b) => b.type === "text");
expect(textBookmark).toBeDefined();
- expect(textBookmark!.archived).toBe(true);
- expect(textBookmark!.notes).toBe("Additional context");
- // Verify bookmark with no path goes to root
- const noCategoryBookmark = createdBookmarks.find(
- (bookmark) =>
- bookmark.content?.type === "link" &&
- bookmark.content.url === "https://example.com/misc",
+ if (!textBookmark) {
+ throw new Error("Expected text bookmark to be staged");
+ }
+ expect(textBookmark.content).toBe("Important notes about the project");
+ expect(textBookmark.note).toBe("Additional context");
+ expect(textBookmark.listIds).toEqual(["Imported/Personal"]);
+
+ // Verify bookmark with empty paths gets root list ID
+ const noCategoryBookmark = stagedBookmarks.find(
+ (b) => b.url === "https://example.com/misc",
);
expect(noCategoryBookmark).toBeDefined();
- // Find the corresponding list assignment for this bookmark
- const noCategoryBookmarkId = `bookmark-${createdBookmarks.indexOf(noCategoryBookmark!) + 1}`;
- const listAssignment = addedToLists.find(
- (a) => a.bookmarkId === noCategoryBookmarkId,
- );
- expect(listAssignment!.listIds).toEqual(["Imported"]);
+ expect(noCategoryBookmark!.listIds).toEqual(["Imported"]);
+
+ // Verify finalizeImportStaging was called
+ expect(finalizeImportStaging).toHaveBeenCalledWith("session-1");
- // Verify that tags were updated for bookmarks that have tags
- expect(updatedTags.length).toBeGreaterThan(0);
expect(progress).toContain(0);
expect(progress.at(-1)).toBe(1);
});
@@ -167,9 +165,8 @@ describe("importBookmarksFromFile", () => {
rootListName: "Imported",
deps: {
createList: vi.fn(),
- createBookmark: vi.fn(),
- addBookmarkToLists: vi.fn(),
- updateBookmarkTags: vi.fn(),
+ stageImportedBookmarks: vi.fn(),
+ finalizeImportStaging: vi.fn(),
createImportSession: vi.fn(async () => ({ id: "session-1" })),
},
},
@@ -182,29 +179,29 @@ describe("importBookmarksFromFile", () => {
});
});
- it("continues import when individual bookmarks fail", async () => {
+ it("stages all bookmarks successfully", async () => {
const parsers = {
pocket: vi.fn().mockReturnValue([
{
- title: "Success Bookmark 1",
- content: { type: "link", url: "https://example.com/success1" },
- tags: ["success"],
+ title: "Bookmark 1",
+ content: { type: "link", url: "https://example.com/1" },
+ tags: ["tag1"],
addDate: 100,
- paths: [["Success"]],
+ paths: [["Category1"]],
},
{
- title: "Failure Bookmark",
- content: { type: "link", url: "https://example.com/failure" },
- tags: ["failure"],
+ title: "Bookmark 2",
+ content: { type: "link", url: "https://example.com/2" },
+ tags: ["tag2"],
addDate: 200,
- paths: [["Failure"]],
+ paths: [["Category2"]],
},
{
- title: "Success Bookmark 2",
- content: { type: "link", url: "https://example.com/success2" },
- tags: ["success"],
+ title: "Bookmark 3",
+ content: { type: "link", url: "https://example.com/3" },
+ tags: ["tag3"],
addDate: 300,
- paths: [["Success"]],
+ paths: [["Category1"]],
},
]),
};
@@ -220,37 +217,23 @@ describe("importBookmarksFromFile", () => {
},
);
- const createdBookmarks: ParsedBookmark[] = [];
- const addedToLists: { bookmarkId: string; listIds: string[] }[] = [];
- const updatedTags: { bookmarkId: string; tags: string[] }[] = [];
-
- const createBookmark = vi.fn(async (bookmark: ParsedBookmark) => {
- // Simulate failure for the "Failure Bookmark"
- if (bookmark.title === "Failure Bookmark") {
- throw new Error("Simulated bookmark creation failure");
- }
-
- createdBookmarks.push(bookmark);
- return {
- id: `bookmark-${createdBookmarks.length}`,
- alreadyExists: false,
- };
- });
-
- const addBookmarkToLists = vi.fn(
- async (input: { bookmarkId: string; listIds: string[] }) => {
- addedToLists.push(input);
+ const stagedBookmarks: StagedBookmark[] = [];
+ const stageImportedBookmarks = vi.fn(
+ async (input: {
+ importSessionId: string;
+ bookmarks: StagedBookmark[];
+ }) => {
+ stagedBookmarks.push(...input.bookmarks);
},
);
- const updateBookmarkTags = vi.fn(
- async (input: { bookmarkId: string; tags: string[] }) => {
- updatedTags.push(input);
- },
+ const finalizeImportStaging = vi.fn();
+ const createImportSession = vi.fn(
+ async (_input: { name: string; rootListId: string }) => ({
+ id: "session-1",
+ }),
);
- const createImportSession = vi.fn(async () => ({ id: "session-1" }));
-
const progress: number[] = [];
const res = await importBookmarksFromFile(
{
@@ -259,9 +242,8 @@ describe("importBookmarksFromFile", () => {
rootListName: "Imported",
deps: {
createList,
- createBookmark,
- addBookmarkToLists,
- updateBookmarkTags,
+ stageImportedBookmarks,
+ finalizeImportStaging,
createImportSession,
},
onProgress: (d, t) => progress.push(d / t),
@@ -269,63 +251,57 @@ describe("importBookmarksFromFile", () => {
{ parsers },
);
- // Should still create the root list
expect(res.rootListId).toBe("Imported");
-
- // Should track both successes and failures
+ expect(res.importSessionId).toBe("session-1");
expect(res.counts).toEqual({
- successes: 2, // Two successful bookmarks
- failures: 1, // One failed bookmark
+ successes: 0,
+ failures: 0,
alreadyExisted: 0,
total: 3,
});
- // Should create folders for all bookmarks (including failed ones)
+ // Should create folders for all bookmarks
expect(createdLists).toEqual([
{ name: "Imported", icon: "⬆️" },
- { name: "Success", parentId: "Imported", icon: "📁" },
- { name: "Failure", parentId: "Imported", icon: "📁" },
+ { name: "Category1", parentId: "Imported", icon: "📁" },
+ { name: "Category2", parentId: "Imported", icon: "📁" },
]);
- // Only successful bookmarks should be created
- expect(createdBookmarks).toHaveLength(2);
- expect(createdBookmarks.map((b) => b.title)).toEqual([
- "Success Bookmark 1",
- "Success Bookmark 2",
- ]);
+ // All bookmarks should be staged (in 1 batch since < 50)
+ expect(stagedBookmarks).toHaveLength(3);
+ expect(stageImportedBookmarks).toHaveBeenCalledTimes(1);
- // Only successful bookmarks should be added to lists and have tags updated
- expect(addedToLists).toHaveLength(2);
- expect(updatedTags).toHaveLength(2);
+ // Verify finalizeImportStaging was called
+ expect(finalizeImportStaging).toHaveBeenCalledWith("session-1");
- // Progress should complete even with failures
+ // Progress should complete
expect(progress).toContain(0);
expect(progress.at(-1)).toBe(1);
});
- it("handles failures in different stages of bookmark import", async () => {
+ it("stages bookmarks with different paths", async () => {
const parsers = {
pocket: vi.fn().mockReturnValue([
{
- title: "Success Bookmark",
- content: { type: "link", url: "https://example.com/success" },
- tags: ["success"],
+ title: "Bookmark 1",
+ content: { type: "link", url: "https://example.com/1" },
+ tags: ["tag1"],
addDate: 100,
- paths: [["Success"]],
+ paths: [["Path1"]],
},
{
- title: "Fail at List Assignment",
- content: { type: "link", url: "https://example.com/fail-list" },
- tags: ["fail"],
+ title: "Bookmark 2",
+ content: { type: "link", url: "https://example.com/2" },
+ tags: ["tag2"],
addDate: 200,
- paths: [["Failure"]],
+ paths: [["Path2"]],
},
{
- title: "Fail at Tag Update",
- content: { type: "link", url: "https://example.com/fail-tag" },
- tags: ["fail-tag"],
+ title: "Bookmark 3",
+ content: { type: "link", url: "https://example.com/3" },
+ tags: ["tag3"],
addDate: 300,
- paths: [["Failure"]],
+ paths: [["Path2"]],
},
]),
};
@@ -338,31 +314,23 @@ describe("importBookmarksFromFile", () => {
},
);
- let bookmarkIdCounter = 1;
- const createBookmark = vi.fn(async () => {
- return { id: `bookmark-${bookmarkIdCounter++}`, alreadyExists: false };
- });
-
- const addBookmarkToLists = vi.fn(
- async (input: { bookmarkId: string; listIds: string[] }) => {
- // Simulate failure for specific bookmark
- if (input.bookmarkId === "bookmark-2") {
- throw new Error("Failed to add bookmark to lists");
- }
+ const stagedBookmarks: StagedBookmark[] = [];
+ const stageImportedBookmarks = vi.fn(
+ async (input: {
+ importSessionId: string;
+ bookmarks: StagedBookmark[];
+ }) => {
+ stagedBookmarks.push(...input.bookmarks);
},
);
- const updateBookmarkTags = vi.fn(
- async (input: { bookmarkId: string; tags: string[] }) => {
- // Simulate failure for specific bookmark
- if (input.bookmarkId === "bookmark-3") {
- throw new Error("Failed to update bookmark tags");
- }
- },
+ const finalizeImportStaging = vi.fn();
+ const createImportSession = vi.fn(
+ async (_input: { name: string; rootListId: string }) => ({
+ id: "session-1",
+ }),
);
- const createImportSession = vi.fn(async () => ({ id: "session-1" }));
-
const progress: number[] = [];
const res = await importBookmarksFromFile(
{
@@ -371,9 +339,8 @@ describe("importBookmarksFromFile", () => {
rootListName: "Imported",
deps: {
createList,
- createBookmark,
- addBookmarkToLists,
- updateBookmarkTags,
+ stageImportedBookmarks,
+ finalizeImportStaging,
createImportSession,
},
onProgress: (d, t) => progress.push(d / t),
@@ -383,23 +350,19 @@ describe("importBookmarksFromFile", () => {
expect(res.rootListId).toBe("Imported");
expect(res.importSessionId).toBe("session-1");
-
- // All bookmarks are created successfully, but 2 fail in post-processing
expect(res.counts).toEqual({
- successes: 1, // Only one fully successful bookmark
- failures: 2, // Two failed in post-processing steps
+ successes: 0,
+ failures: 0,
alreadyExisted: 0,
total: 3,
});
- // All bookmarks should be created (failures happen after bookmark creation)
- expect(createBookmark).toHaveBeenCalledTimes(3);
-
- // addBookmarkToLists should be called 3 times (but one fails)
- expect(addBookmarkToLists).toHaveBeenCalledTimes(3);
+ // All bookmarks should be staged (in 1 batch since < 50)
+ expect(stagedBookmarks).toHaveLength(3);
+ expect(stageImportedBookmarks).toHaveBeenCalledTimes(1);
- // updateBookmarkTags should be called 2 times (once fails at list assignment, one fails at tag update)
- expect(updateBookmarkTags).toHaveBeenCalledTimes(2);
+ // Verify finalizeImportStaging was called
+ expect(finalizeImportStaging).toHaveBeenCalledWith("session-1");
});
it("handles HTML bookmarks with empty folder names", async () => {
@@ -432,14 +395,22 @@ describe("importBookmarksFromFile", () => {
},
);
- const createdBookmarks: ParsedBookmark[] = [];
- const createBookmark = vi.fn(async (bookmark: ParsedBookmark) => {
- createdBookmarks.push(bookmark);
- return {
- id: `bookmark-${createdBookmarks.length}`,
- alreadyExists: false,
- };
- });
+ const stagedBookmarks: StagedBookmark[] = [];
+ const stageImportedBookmarks = vi.fn(
+ async (input: {
+ importSessionId: string;
+ bookmarks: StagedBookmark[];
+ }) => {
+ stagedBookmarks.push(...input.bookmarks);
+ },
+ );
+
+ const finalizeImportStaging = vi.fn();
+ const createImportSession = vi.fn(
+ async (_input: { name: string; rootListId: string }) => ({
+ id: "session-1",
+ }),
+ );
const res = await importBookmarksFromFile({
file: mockFile,
@@ -447,15 +418,14 @@ describe("importBookmarksFromFile", () => {
rootListName: "HTML Import",
deps: {
createList,
- createBookmark,
- addBookmarkToLists: vi.fn(),
- updateBookmarkTags: vi.fn(),
- createImportSession: vi.fn(async () => ({ id: "session-1" })),
+ stageImportedBookmarks,
+ finalizeImportStaging,
+ createImportSession,
},
});
expect(res.counts).toEqual({
- successes: 1,
+ successes: 0,
failures: 0,
alreadyExisted: 0,
total: 1,
@@ -472,16 +442,18 @@ describe("importBookmarksFromFile", () => {
},
]);
- // Verify the bookmark was created and assigned to the correct path
- expect(createdBookmarks).toHaveLength(1);
- expect(createdBookmarks[0]).toMatchObject({
+ // Verify the bookmark was staged with correct listIds
+ expect(stagedBookmarks).toHaveLength(1);
+ expect(stagedBookmarks[0]).toMatchObject({
title: "Example Product",
- content: {
- type: "link",
- url: "https://www.example.com/product.html",
- },
+ url: "https://www.example.com/product.html",
+ type: "link",
tags: [],
+ listIds: ["HTML Import/Bluetooth Fernbedienung/Unnamed"],
});
+
+ // Verify finalizeImportStaging was called
+ expect(finalizeImportStaging).toHaveBeenCalledWith("session-1");
});
it("parses mymind CSV export correctly", async () => {
@@ -495,14 +467,22 @@ describe("importBookmarksFromFile", () => {
text: vi.fn().mockResolvedValue(mymindCsv),
} as unknown as File;
- const createdBookmarks: ParsedBookmark[] = [];
- const createBookmark = vi.fn(async (bookmark: ParsedBookmark) => {
- createdBookmarks.push(bookmark);
- return {
- id: `bookmark-${createdBookmarks.length}`,
- alreadyExists: false,
- };
- });
+ const stagedBookmarks: StagedBookmark[] = [];
+ const stageImportedBookmarks = vi.fn(
+ async (input: {
+ importSessionId: string;
+ bookmarks: StagedBookmark[];
+ }) => {
+ stagedBookmarks.push(...input.bookmarks);
+ },
+ );
+
+ const finalizeImportStaging = vi.fn();
+ const createImportSession = vi.fn(
+ async (_input: { name: string; rootListId: string }) => ({
+ id: "session-1",
+ }),
+ );
const res = await importBookmarksFromFile({
file: mockFile,
@@ -514,52 +494,54 @@ describe("importBookmarksFromFile", () => {
id: `${input.parentId ? input.parentId + "/" : ""}${input.name}`,
}),
),
- createBookmark,
- addBookmarkToLists: vi.fn(),
- updateBookmarkTags: vi.fn(),
- createImportSession: vi.fn(async () => ({ id: "session-1" })),
+ stageImportedBookmarks,
+ finalizeImportStaging,
+ createImportSession,
},
});
expect(res.counts).toEqual({
- successes: 3,
+ successes: 0,
failures: 0,
alreadyExisted: 0,
total: 3,
});
- // Verify first bookmark (WebPage with URL)
- expect(createdBookmarks[0]).toMatchObject({
+ // Verify 3 bookmarks were staged
+ expect(stagedBookmarks).toHaveLength(3);
+
+ // Verify first bookmark (WebPage with URL) - mymind has no paths, so root list
+ expect(stagedBookmarks[0]).toMatchObject({
title: "mymind",
- content: {
- type: "link",
- url: "https://access.mymind.com/everything",
- },
+ url: "https://access.mymind.com/everything",
+ type: "link",
tags: ["Wellness", "Self-Improvement", "Psychology"],
+ listIds: ["mymind Import"],
});
- expect(createdBookmarks[0].addDate).toBeCloseTo(
- new Date("2024-12-04T23:02:10Z").getTime() / 1000,
+ expect(stagedBookmarks[0].sourceAddedAt).toEqual(
+ new Date("2024-12-04T23:02:10Z"),
);
// Verify second bookmark (WebPage with note)
- expect(createdBookmarks[1]).toMatchObject({
+ expect(stagedBookmarks[1]).toMatchObject({
title: "Movies / TV / Anime",
- content: {
- type: "link",
- url: "https://fmhy.pages.dev/videopiracyguide",
- },
+ url: "https://fmhy.pages.dev/videopiracyguide",
+ type: "link",
tags: ["Tools", "media", "Entertainment"],
- notes: "Free Media!",
+ note: "Free Media!",
+ listIds: ["mymind Import"],
});
// Verify third bookmark (Note with text content)
- expect(createdBookmarks[2]).toMatchObject({
+ expect(stagedBookmarks[2]).toMatchObject({
title: "",
- content: {
- type: "text",
- text: "• Critical Thinking\n• Empathy",
- },
+ content: "• Critical Thinking\n• Empathy",
+ type: "text",
tags: [],
+ listIds: ["mymind Import"],
});
+
+ // Verify finalizeImportStaging was called
+ expect(finalizeImportStaging).toHaveBeenCalledWith("session-1");
});
});
diff --git a/packages/shared/import-export/importer.ts b/packages/shared/import-export/importer.ts
index b32c49c1..be24ca73 100644
--- a/packages/shared/import-export/importer.ts
+++ b/packages/shared/import-export/importer.ts
@@ -1,4 +1,3 @@
-import { limitConcurrency } from "../concurrency";
import { MAX_LIST_NAME_LENGTH } from "../types/lists";
import { ImportSource, ParsedBookmark, parseImportFile } from "./parsers";
@@ -9,28 +8,32 @@ export interface ImportCounts {
total: number;
}
+export interface StagedBookmark {
+ type: "link" | "text" | "asset";
+ url?: string;
+ title?: string;
+ content?: string;
+ note?: string;
+ tags: string[];
+ listIds: string[];
+ sourceAddedAt?: Date;
+}
+
export interface ImportDeps {
createList: (input: {
name: string;
icon: string;
parentId?: string;
}) => Promise<{ id: string }>;
- createBookmark: (
- bookmark: ParsedBookmark,
- sessionId: string,
- ) => Promise<{ id: string; alreadyExists?: boolean }>;
- addBookmarkToLists: (input: {
- bookmarkId: string;
- listIds: string[];
- }) => Promise<void>;
- updateBookmarkTags: (input: {
- bookmarkId: string;
- tags: string[];
+ stageImportedBookmarks: (input: {
+ importSessionId: string;
+ bookmarks: StagedBookmark[];
}) => Promise<void>;
createImportSession: (input: {
name: string;
rootListId: string;
}) => Promise<{ id: string }>;
+ finalizeImportStaging: (sessionId: string) => Promise<void>;
}
export interface ImportOptions {
@@ -62,7 +65,7 @@ export async function importBookmarksFromFile(
},
options: ImportOptions = {},
): Promise<ImportResult> {
- const { concurrencyLimit = 20, parsers } = options;
+ const { parsers } = options;
const textContent = await file.text();
const parsedBookmarks = parsers?.[source]
@@ -120,50 +123,74 @@ export async function importBookmarksFromFile(
pathMap[pathKey] = folderList.id;
}
- let done = 0;
- const importPromises = parsedBookmarks.map((bookmark) => async () => {
- try {
- const listIds = bookmark.paths.map(
- (path) => pathMap[path.join(PATH_DELIMITER)] || rootList.id,
- );
- if (listIds.length === 0) listIds.push(rootList.id);
-
- const created = await deps.createBookmark(bookmark, session.id);
- await deps.addBookmarkToLists({ bookmarkId: created.id, listIds });
- if (bookmark.tags && bookmark.tags.length > 0) {
- await deps.updateBookmarkTags({
- bookmarkId: created.id,
- tags: bookmark.tags,
- });
+ // Prepare all bookmarks for staging
+ const bookmarksToStage: StagedBookmark[] = parsedBookmarks.map((bookmark) => {
+ // Convert paths to list IDs using pathMap
+ // If no paths, assign to root list
+ const listIds =
+ bookmark.paths.length === 0
+ ? [rootList.id]
+ : bookmark.paths
+ .map((path) => {
+ if (path.length === 0) {
+ return rootList.id;
+ }
+ const pathKey = path.join(PATH_DELIMITER);
+ return pathMap[pathKey] || rootList.id;
+ })
+ .filter((id, index, arr) => arr.indexOf(id) === index); // dedupe
+
+ // Determine type and extract content appropriately
+ let type: "link" | "text" | "asset" = "link";
+ let url: string | undefined;
+ let textContent: string | undefined;
+
+ if (bookmark.content) {
+ if (bookmark.content.type === "link") {
+ type = "link";
+ url = bookmark.content.url;
+ } else if (bookmark.content.type === "text") {
+ type = "text";
+ textContent = bookmark.content.text;
}
-
- return created;
- } finally {
- done += 1;
- onProgress?.(done, parsedBookmarks.length);
}
- });
- const resultsPromises = limitConcurrency(importPromises, concurrencyLimit);
- const results = await Promise.allSettled(resultsPromises);
+ return {
+ type,
+ url,
+ title: bookmark.title,
+ content: textContent,
+ note: bookmark.notes,
+ tags: bookmark.tags ?? [],
+ listIds,
+ sourceAddedAt: bookmark.addDate
+ ? new Date(bookmark.addDate * 1000)
+ : undefined,
+ };
+ });
- let successes = 0;
- let failures = 0;
- let alreadyExisted = 0;
+ // Stage bookmarks in batches of 50
+ const BATCH_SIZE = 50;
+ let staged = 0;
- for (const r of results) {
- if (r.status === "fulfilled") {
- if (r.value.alreadyExists) alreadyExisted++;
- else successes++;
- } else {
- failures++;
- }
+ for (let i = 0; i < bookmarksToStage.length; i += BATCH_SIZE) {
+ const batch = bookmarksToStage.slice(i, i + BATCH_SIZE);
+ await deps.stageImportedBookmarks({
+ importSessionId: session.id,
+ bookmarks: batch,
+ });
+ staged += batch.length;
+ onProgress?.(staged, parsedBookmarks.length);
}
+
+ // Finalize staging - marks session as "pending" for worker pickup
+ await deps.finalizeImportStaging(session.id);
+
return {
counts: {
- successes,
- failures,
- alreadyExisted,
+ successes: 0,
+ failures: 0,
+ alreadyExisted: 0,
total: parsedBookmarks.length,
},
rootListId: rootList.id,
diff --git a/packages/shared/index.ts b/packages/shared/index.ts
index e69de29b..cb0ff5c3 100644
--- a/packages/shared/index.ts
+++ b/packages/shared/index.ts
@@ -0,0 +1 @@
+export {};
diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index ec27c393..2f32bd51 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -164,6 +164,7 @@ export const zNewBookmarkRequestSchema = z
// A mechanism to prioritize crawling of bookmarks depending on whether
// they were created by a user interaction or by a bulk import.
crawlPriority: z.enum(["low", "normal"]).optional(),
+ // Deprecated
importSessionId: z.string().optional(),
source: zBookmarkSourceSchema.optional(),
})
diff --git a/packages/shared/types/importSessions.ts b/packages/shared/types/importSessions.ts
index 0c1edd03..44022a74 100644
--- a/packages/shared/types/importSessions.ts
+++ b/packages/shared/types/importSessions.ts
@@ -1,8 +1,10 @@
import { z } from "zod";
export const zImportSessionStatusSchema = z.enum([
+ "staging",
"pending",
- "in_progress",
+ "running",
+ "paused",
"completed",
"failed",
]);
@@ -24,13 +26,13 @@ export const zImportSessionSchema = z.object({
userId: z.string(),
message: z.string().nullable(),
rootListId: z.string().nullable(),
+ status: zImportSessionStatusSchema,
createdAt: z.date(),
modifiedAt: z.date().nullable(),
});
export type ZImportSession = z.infer<typeof zImportSessionSchema>;
export const zImportSessionWithStatsSchema = zImportSessionSchema.extend({
- status: z.enum(["pending", "in_progress", "completed", "failed"]),
totalBookmarks: z.number(),
completedBookmarks: z.number(),
failedBookmarks: z.number(),
diff --git a/packages/trpc/models/importSessions.ts b/packages/trpc/models/importSessions.ts
index c324cf7f..ee0eb5b2 100644
--- a/packages/trpc/models/importSessions.ts
+++ b/packages/trpc/models/importSessions.ts
@@ -2,12 +2,7 @@ import { TRPCError } from "@trpc/server";
import { and, count, eq } from "drizzle-orm";
import { z } from "zod";
-import {
- bookmarkLinks,
- bookmarks,
- importSessionBookmarks,
- importSessions,
-} from "@karakeep/db/schema";
+import { importSessions, importStagingBookmarks } from "@karakeep/db/schema";
import {
zCreateImportSessionRequestSchema,
ZImportSession,
@@ -81,38 +76,17 @@ export class ImportSession {
);
}
- async attachBookmark(bookmarkId: string): Promise<void> {
- await this.ctx.db.insert(importSessionBookmarks).values({
- importSessionId: this.session.id,
- bookmarkId,
- });
- }
-
async getWithStats(): Promise<ZImportSessionWithStats> {
- // Get bookmark counts by status
+ // Count by staging status - this now reflects the true state since
+ // items stay in "processing" until downstream crawl/tag is complete
const statusCounts = await this.ctx.db
.select({
- crawlStatus: bookmarkLinks.crawlStatus,
- taggingStatus: bookmarks.taggingStatus,
+ status: importStagingBookmarks.status,
count: count(),
})
- .from(importSessionBookmarks)
- .innerJoin(
- importSessions,
- eq(importSessions.id, importSessionBookmarks.importSessionId),
- )
- .leftJoin(bookmarks, eq(bookmarks.id, importSessionBookmarks.bookmarkId))
- .leftJoin(
- bookmarkLinks,
- eq(bookmarkLinks.id, importSessionBookmarks.bookmarkId),
- )
- .where(
- and(
- eq(importSessionBookmarks.importSessionId, this.session.id),
- eq(importSessions.userId, this.ctx.user.id),
- ),
- )
- .groupBy(bookmarkLinks.crawlStatus, bookmarks.taggingStatus);
+ .from(importStagingBookmarks)
+ .where(eq(importStagingBookmarks.importSessionId, this.session.id))
+ .groupBy(importStagingBookmarks.status);
const stats = {
totalBookmarks: 0,
@@ -122,41 +96,27 @@ export class ImportSession {
processingBookmarks: 0,
};
- statusCounts.forEach((statusCount) => {
- const { crawlStatus, taggingStatus, count } = statusCount;
-
- stats.totalBookmarks += count;
-
- const isCrawlFailure = crawlStatus === "failure";
- const isTagFailure = taggingStatus === "failure";
- if (isCrawlFailure || isTagFailure) {
- stats.failedBookmarks += count;
- return;
- }
-
- const isCrawlPending = crawlStatus === "pending";
- const isTagPending = taggingStatus === "pending";
- if (isCrawlPending || isTagPending) {
- stats.pendingBookmarks += count;
- return;
- }
-
- const isCrawlSuccessfulOrNotRequired =
- crawlStatus === "success" || crawlStatus === null;
- const isTagSuccessfulOrUnknown =
- taggingStatus === "success" || taggingStatus === null;
-
- if (isCrawlSuccessfulOrNotRequired && isTagSuccessfulOrUnknown) {
- stats.completedBookmarks += count;
- } else {
- // Fallback to pending to avoid leaving imports unclassified
- stats.pendingBookmarks += count;
+ statusCounts.forEach(({ status, count: itemCount }) => {
+ stats.totalBookmarks += itemCount;
+
+ switch (status) {
+ case "pending":
+ stats.pendingBookmarks += itemCount;
+ break;
+ case "processing":
+ stats.processingBookmarks += itemCount;
+ break;
+ case "completed":
+ stats.completedBookmarks += itemCount;
+ break;
+ case "failed":
+ stats.failedBookmarks += itemCount;
+ break;
}
});
return {
...this.session,
- status: stats.pendingBookmarks > 0 ? "in_progress" : "completed",
...stats,
};
}
@@ -179,4 +139,92 @@ export class ImportSession {
});
}
}
+
+ async stageBookmarks(
+ bookmarks: {
+ type: "link" | "text" | "asset";
+ url?: string;
+ title?: string;
+ content?: string;
+ note?: string;
+ tags: string[];
+ listIds: string[];
+ sourceAddedAt?: Date;
+ }[],
+ ): Promise<void> {
+ if (this.session.status !== "staging") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Session not in staging status",
+ });
+ }
+
+ // Filter out invalid bookmarks (link without url, text without content)
+ const validBookmarks = bookmarks.filter((bookmark) => {
+ if (bookmark.type === "link" && !bookmark.url) return false;
+ if (bookmark.type === "text" && !bookmark.content) return false;
+ return true;
+ });
+
+ if (validBookmarks.length === 0) {
+ return;
+ }
+
+ await this.ctx.db.insert(importStagingBookmarks).values(
+ validBookmarks.map((bookmark) => ({
+ importSessionId: this.session.id,
+ type: bookmark.type,
+ url: bookmark.url,
+ title: bookmark.title,
+ content: bookmark.content,
+ note: bookmark.note,
+ tags: bookmark.tags,
+ listIds: bookmark.listIds,
+ sourceAddedAt: bookmark.sourceAddedAt,
+ status: "pending" as const,
+ })),
+ );
+ }
+
+ async finalize(): Promise<void> {
+ if (this.session.status !== "staging") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Session not in staging status",
+ });
+ }
+
+ await this.ctx.db
+ .update(importSessions)
+ .set({ status: "pending" })
+ .where(eq(importSessions.id, this.session.id));
+ }
+
+ async pause(): Promise<void> {
+ if (!["pending", "running"].includes(this.session.status)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Session cannot be paused in current status",
+ });
+ }
+
+ await this.ctx.db
+ .update(importSessions)
+ .set({ status: "paused" })
+ .where(eq(importSessions.id, this.session.id));
+ }
+
+ async resume(): Promise<void> {
+ if (this.session.status !== "paused") {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Session not paused",
+ });
+ }
+
+ await this.ctx.db
+ .update(importSessions)
+ .set({ status: "pending" })
+ .where(eq(importSessions.id, this.session.id));
+ }
}
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
index 882ff9b1..59c93581 100644
--- a/packages/trpc/routers/bookmarks.ts
+++ b/packages/trpc/routers/bookmarks.ts
@@ -51,7 +51,6 @@ import { authedProcedure, createRateLimitMiddleware, router } from "../index";
import { getBookmarkIdsFromMatcher } from "../lib/search";
import { Asset } from "../models/assets";
import { BareBookmark, Bookmark } from "../models/bookmarks";
-import { ImportSession } from "../models/importSessions";
export const ensureBookmarkOwnership = experimental_trpcMiddleware<{
ctx: AuthedContext;
@@ -121,13 +120,6 @@ export const bookmarksAppRouter = router({
// This doesn't 100% protect from duplicates because of races, but it's more than enough for this usecase.
const alreadyExists = await attemptToDedupLink(ctx, input.url);
if (alreadyExists) {
- if (input.importSessionId) {
- const session = await ImportSession.fromId(
- ctx,
- input.importSessionId,
- );
- await session.attachBookmark(alreadyExists.id);
- }
return { ...alreadyExists, alreadyExists: true };
}
}
@@ -277,11 +269,6 @@ export const bookmarksAppRouter = router({
},
);
- if (input.importSessionId) {
- const session = await ImportSession.fromId(ctx, input.importSessionId);
- await session.attachBookmark(bookmark.id);
- }
-
const enqueueOpts: EnqueueOptions = {
// The lower the priority number, the sooner the job will be processed
priority: input.crawlPriority === "low" ? 50 : 0,
diff --git a/packages/trpc/routers/importSessions.test.ts b/packages/trpc/routers/importSessions.test.ts
index 9ef0de6f..f257ad3b 100644
--- a/packages/trpc/routers/importSessions.test.ts
+++ b/packages/trpc/routers/importSessions.test.ts
@@ -1,12 +1,13 @@
-import { eq } from "drizzle-orm";
import { beforeEach, describe, expect, test } from "vitest";
import { z } from "zod";
-import { bookmarks } from "@karakeep/db/schema";
import {
- BookmarkTypes,
- zNewBookmarkRequestSchema,
-} from "@karakeep/shared/types/bookmarks";
+ bookmarkLinks,
+ bookmarks,
+ bookmarkTexts,
+ importStagingBookmarks,
+} from "@karakeep/db/schema";
+import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import {
zCreateImportSessionRequestSchema,
zDeleteImportSessionRequestSchema,
@@ -20,17 +21,6 @@ import { defaultBeforeEach } from "../testUtils";
beforeEach<CustomTestContext>(defaultBeforeEach(true));
describe("ImportSessions Routes", () => {
- async function createTestBookmark(api: APICallerType, sessionId: string) {
- const newBookmarkInput: z.infer<typeof zNewBookmarkRequestSchema> = {
- type: BookmarkTypes.TEXT,
- text: "Test bookmark text",
- importSessionId: sessionId,
- };
- const createdBookmark =
- await api.bookmarks.createBookmark(newBookmarkInput);
- return createdBookmark.id;
- }
-
async function createTestList(api: APICallerType) {
const newListInput: z.infer<typeof zNewBookmarkListSchema> = {
name: "Test Import List",
@@ -98,8 +88,15 @@ describe("ImportSessions Routes", () => {
const session = await api.importSessions.createImportSession({
name: "Test Import Session",
});
- await createTestBookmark(api, session.id);
- await createTestBookmark(api, session.id);
+
+ // Stage bookmarks using the staging flow
+ await api.importSessions.stageImportedBookmarks({
+ importSessionId: session.id,
+ bookmarks: [
+ { type: "text", content: "Test bookmark 1", tags: [], listIds: [] },
+ { type: "text", content: "Test bookmark 2", tags: [], listIds: [] },
+ ],
+ });
const statsInput: z.infer<typeof zGetImportSessionStatsRequestSchema> = {
importSessionId: session.id,
@@ -110,7 +107,7 @@ describe("ImportSessions Routes", () => {
expect(stats).toMatchObject({
id: session.id,
name: "Test Import Session",
- status: "in_progress",
+ status: "staging",
totalBookmarks: 2,
pendingBookmarks: 2,
completedBookmarks: 0,
@@ -119,31 +116,191 @@ describe("ImportSessions Routes", () => {
});
});
- test<CustomTestContext>("marks text-only imports as completed when tagging succeeds", async ({
+ test<CustomTestContext>("stats reflect crawl and tagging status for completed staging bookmarks", async ({
apiCallers,
db,
}) => {
const api = apiCallers[0];
+
const session = await api.importSessions.createImportSession({
- name: "Text Import Session",
+ name: "Test Import Session",
+ });
+
+ // Create bookmarks with different crawl/tag statuses
+ const user = (await db.query.users.findFirst())!;
+
+ // 1. Link bookmark: crawl success, tag success -> completed
+ const [completedLinkBookmark] = await db
+ .insert(bookmarks)
+ .values({
+ userId: user.id,
+ type: BookmarkTypes.LINK,
+ taggingStatus: "success",
+ })
+ .returning();
+ await db.insert(bookmarkLinks).values({
+ id: completedLinkBookmark.id,
+ url: "https://example.com/1",
+ crawlStatus: "success",
+ });
+
+ // 2. Link bookmark: crawl pending, tag success -> processing
+ const [crawlPendingBookmark] = await db
+ .insert(bookmarks)
+ .values({
+ userId: user.id,
+ type: BookmarkTypes.LINK,
+ taggingStatus: "success",
+ })
+ .returning();
+ await db.insert(bookmarkLinks).values({
+ id: crawlPendingBookmark.id,
+ url: "https://example.com/2",
+ crawlStatus: "pending",
+ });
+
+ // 3. Text bookmark: tag pending -> processing
+ const [tagPendingBookmark] = await db
+ .insert(bookmarks)
+ .values({
+ userId: user.id,
+ type: BookmarkTypes.TEXT,
+ taggingStatus: "pending",
+ })
+ .returning();
+ await db.insert(bookmarkTexts).values({
+ id: tagPendingBookmark.id,
+ text: "Test text",
+ });
+
+ // 4. Link bookmark: crawl failure -> failed
+ const [crawlFailedBookmark] = await db
+ .insert(bookmarks)
+ .values({
+ userId: user.id,
+ type: BookmarkTypes.LINK,
+ taggingStatus: "success",
+ })
+ .returning();
+ await db.insert(bookmarkLinks).values({
+ id: crawlFailedBookmark.id,
+ url: "https://example.com/3",
+ crawlStatus: "failure",
});
- const bookmarkId = await createTestBookmark(api, session.id);
- await db
- .update(bookmarks)
- .set({ taggingStatus: "success" })
- .where(eq(bookmarks.id, bookmarkId));
+ // 5. Text bookmark: tag failure -> failed
+ const [tagFailedBookmark] = await db
+ .insert(bookmarks)
+ .values({
+ userId: user.id,
+ type: BookmarkTypes.TEXT,
+ taggingStatus: "failure",
+ })
+ .returning();
+ await db.insert(bookmarkTexts).values({
+ id: tagFailedBookmark.id,
+ text: "Test text 2",
+ });
+
+ // 6. Text bookmark: tag success (no crawl needed) -> completed
+ const [completedTextBookmark] = await db
+ .insert(bookmarks)
+ .values({
+ userId: user.id,
+ type: BookmarkTypes.TEXT,
+ taggingStatus: "success",
+ })
+ .returning();
+ await db.insert(bookmarkTexts).values({
+ id: completedTextBookmark.id,
+ text: "Test text 3",
+ });
+
+ // Create staging bookmarks in different states
+ // Note: With the new import worker design, items stay in "processing" until
+ // crawl/tag is done. Only then do they move to "completed".
+ await db.insert(importStagingBookmarks).values([
+ // Staging pending -> pendingBookmarks
+ {
+ importSessionId: session.id,
+ type: "text",
+ content: "pending staging",
+ status: "pending",
+ },
+ // Staging processing (no bookmark yet) -> processingBookmarks
+ {
+ importSessionId: session.id,
+ type: "text",
+ content: "processing staging",
+ status: "processing",
+ },
+ // Staging failed -> failedBookmarks
+ {
+ importSessionId: session.id,
+ type: "text",
+ content: "failed staging",
+ status: "failed",
+ },
+ // Staging completed + crawl/tag success -> completedBookmarks
+ {
+ importSessionId: session.id,
+ type: "link",
+ url: "https://example.com/1",
+ status: "completed",
+ resultBookmarkId: completedLinkBookmark.id,
+ },
+ // Staging processing + crawl pending -> processingBookmarks (waiting for crawl)
+ {
+ importSessionId: session.id,
+ type: "link",
+ url: "https://example.com/2",
+ status: "processing",
+ resultBookmarkId: crawlPendingBookmark.id,
+ },
+ // Staging processing + tag pending -> processingBookmarks (waiting for tag)
+ {
+ importSessionId: session.id,
+ type: "text",
+ content: "tag pending",
+ status: "processing",
+ resultBookmarkId: tagPendingBookmark.id,
+ },
+ // Staging completed + crawl failure -> completedBookmarks (failure is terminal)
+ {
+ importSessionId: session.id,
+ type: "link",
+ url: "https://example.com/3",
+ status: "completed",
+ resultBookmarkId: crawlFailedBookmark.id,
+ },
+ // Staging completed + tag failure -> completedBookmarks (failure is terminal)
+ {
+ importSessionId: session.id,
+ type: "text",
+ content: "tag failed",
+ status: "completed",
+ resultBookmarkId: tagFailedBookmark.id,
+ },
+ // Staging completed + tag success (text, no crawl) -> completedBookmarks
+ {
+ importSessionId: session.id,
+ type: "text",
+ content: "completed text",
+ status: "completed",
+ resultBookmarkId: completedTextBookmark.id,
+ },
+ ]);
const stats = await api.importSessions.getImportSessionStats({
importSessionId: session.id,
});
expect(stats).toMatchObject({
- completedBookmarks: 1,
- pendingBookmarks: 0,
- failedBookmarks: 0,
- totalBookmarks: 1,
- status: "completed",
+ totalBookmarks: 9,
+ pendingBookmarks: 1, // staging pending
+ processingBookmarks: 3, // staging processing (no bookmark) + crawl pending + tag pending
+ completedBookmarks: 4, // link success + text success + crawl failure + tag failure
+ failedBookmarks: 1, // staging failed
});
});
@@ -215,7 +372,7 @@ describe("ImportSessions Routes", () => {
).rejects.toThrow("Import session not found");
});
- test<CustomTestContext>("cannot attach other user's bookmark", async ({
+ test<CustomTestContext>("cannot stage other user's session", async ({
apiCallers,
}) => {
const api1 = apiCallers[0];
@@ -228,7 +385,17 @@ describe("ImportSessions Routes", () => {
// User 1 tries to attach User 2's bookmark
await expect(
- createTestBookmark(api2, session.id), // User 2's bookmark
+ api2.importSessions.stageImportedBookmarks({
+ importSessionId: session.id,
+ bookmarks: [
+ {
+ type: "text",
+ content: "Test bookmark",
+ tags: [],
+ listIds: [],
+ },
+ ],
+ }),
).rejects.toThrow("Import session not found");
});
});
diff --git a/packages/trpc/routers/importSessions.ts b/packages/trpc/routers/importSessions.ts
index 4bdc4f29..62263bdd 100644
--- a/packages/trpc/routers/importSessions.ts
+++ b/packages/trpc/routers/importSessions.ts
@@ -1,5 +1,8 @@
+import { experimental_trpcMiddleware } from "@trpc/server";
+import { and, eq, gt } from "drizzle-orm";
import { z } from "zod";
+import { importStagingBookmarks } from "@karakeep/db/schema";
import {
zCreateImportSessionRequestSchema,
zDeleteImportSessionRequestSchema,
@@ -9,9 +12,26 @@ import {
zListImportSessionsResponseSchema,
} from "@karakeep/shared/types/importSessions";
+import type { AuthedContext } from "../index";
import { authedProcedure, router } from "../index";
import { ImportSession } from "../models/importSessions";
+const ensureImportSessionAccess = experimental_trpcMiddleware<{
+ ctx: AuthedContext;
+ input: { importSessionId: string };
+}>().create(async (opts) => {
+ const importSession = await ImportSession.fromId(
+ opts.ctx,
+ opts.input.importSessionId,
+ );
+ return opts.next({
+ ctx: {
+ ...opts.ctx,
+ importSession,
+ },
+ });
+});
+
export const importSessionsRouter = router({
createImportSession: authedProcedure
.input(zCreateImportSessionRequestSchema)
@@ -45,4 +65,93 @@ export const importSessionsRouter = router({
await session.delete();
return { success: true };
}),
+
+ stageImportedBookmarks: authedProcedure
+ .input(
+ z.object({
+ importSessionId: z.string(),
+ bookmarks: z
+ .array(
+ z.object({
+ type: z.enum(["link", "text", "asset"]),
+ url: z.string().optional(),
+ title: z.string().optional(),
+ content: z.string().optional(),
+ note: z.string().optional(),
+ tags: z.array(z.string()).default([]),
+ listIds: z.array(z.string()).default([]),
+ sourceAddedAt: z.date().optional(),
+ }),
+ )
+ .max(50),
+ }),
+ )
+ .use(ensureImportSessionAccess)
+ .mutation(async ({ input, ctx }) => {
+ await ctx.importSession.stageBookmarks(input.bookmarks);
+ }),
+
+ finalizeImportStaging: authedProcedure
+ .input(z.object({ importSessionId: z.string() }))
+ .use(ensureImportSessionAccess)
+ .mutation(async ({ ctx }) => {
+ await ctx.importSession.finalize();
+ }),
+
+ pauseImportSession: authedProcedure
+ .input(z.object({ importSessionId: z.string() }))
+ .use(ensureImportSessionAccess)
+ .mutation(async ({ ctx }) => {
+ await ctx.importSession.pause();
+ }),
+
+ resumeImportSession: authedProcedure
+ .input(z.object({ importSessionId: z.string() }))
+ .use(ensureImportSessionAccess)
+ .mutation(async ({ ctx }) => {
+ await ctx.importSession.resume();
+ }),
+
+ getImportSessionResults: authedProcedure
+ .input(
+ z.object({
+ importSessionId: z.string(),
+ filter: z
+ .enum(["all", "accepted", "rejected", "skipped_duplicate", "pending"])
+ .optional(),
+ cursor: z.string().optional(),
+ limit: z.number().default(50),
+ }),
+ )
+ .use(ensureImportSessionAccess)
+ .query(async ({ ctx, input }) => {
+ const results = await ctx.db
+ .select()
+ .from(importStagingBookmarks)
+ .where(
+ and(
+ eq(
+ importStagingBookmarks.importSessionId,
+ ctx.importSession.session.id,
+ ),
+ input.filter && input.filter !== "all"
+ ? input.filter === "pending"
+ ? eq(importStagingBookmarks.status, "pending")
+ : eq(importStagingBookmarks.result, input.filter)
+ : undefined,
+ input.cursor
+ ? gt(importStagingBookmarks.id, input.cursor)
+ : undefined,
+ ),
+ )
+ .orderBy(importStagingBookmarks.id)
+ .limit(input.limit + 1);
+
+ // Return with pagination info
+ const hasMore = results.length > input.limit;
+ return {
+ items: results.slice(0, input.limit),
+ nextCursor: hasMore ? results[input.limit - 1].id : null,
+ };
+ }),
});