openreader/eslint.config.mjs
Richard R b2bbd8fbef feat(data-storage): migrate all user state from IndexedDB to server-backed storage
Remove all Dexie/IndexedDB code and dependencies, including document and preview caches, local config, onboarding, and migration logic. Replace with server-backed React Query hooks for documents, folders, preferences, onboarding, and progress. Add browser Cache Storage for blob caching of documents, previews, and audio. Update API routes, database schema, and tests to support folder management, onboarding state, and server-side persistence of all user data. Refactor UI and hooks to use server state exclusively, ensuring all user state is synced and portable across devices.

BREAKING CHANGE: All user data, preferences, onboarding, and document state are now stored and synced on the server; browser IndexedDB is no longer used. Existing local-only data will not be available after this update.
2026-06-14 18:13:39 -06:00

223 lines
9.2 KiB
JavaScript

import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const LOGGER_LEVEL_METHOD = "trace|debug|info|warn|error|fatal";
const STATIC_LOGGER_CALL_SELECTOR =
`CallExpression[callee.type='MemberExpression'][callee.computed=false][callee.property.name=/^(${LOGGER_LEVEL_METHOD})$/]`;
const DYNAMIC_LOGGER_CALL_SELECTOR =
"CallExpression[callee.type='MemberExpression'][callee.computed=true]";
const LOGGER_RECEIVER_SELECTOR =
":matches([callee.object.name=/^(logger|serverLogger)$/],[callee.object.property.name='logger'])";
const SERVER_LOGGER_CALL_SELECTOR = `:matches(${STATIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR},${DYNAMIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR})`;
const NEXT_RESPONSE_ERROR_JSON_SELECTOR =
"CallExpression[callee.type='MemberExpression'][callee.object.name='NextResponse'][callee.property.name='json'][arguments.0.type='ObjectExpression']:has(Property[key.name='error'])";
const APP_DESIGN_SYSTEM_FILES = [
"src/app/(app)/**/*.{ts,tsx}",
"src/components/**/*.{ts,tsx}",
];
const UI_ARCHITECTURE_FILES = [
"src/app/(app)/**/*.{ts,tsx}",
"src/components/auth/**/*.{ts,tsx}",
"src/components/doclist/**/*.{ts,tsx}",
"src/components/documents/DocumentUploader.tsx",
"src/components/documents/DocumentSelectionModal.tsx",
"src/components/documents/ZoomControl.tsx",
"src/components/player/**/*.{ts,tsx}",
"src/components/reader/**/*.{ts,tsx}",
];
const COMPUTE_WORKER_IMPORT_PATTERNS = [
{
group: [
"@openreader/compute-worker",
"@openreader/compute-worker/*",
],
message:
"The app must communicate with compute-worker through the app-owned HTTP client and generated protocol types.",
},
];
const UI_ARCHITECTURE_IMPORT_PATHS = [
{
name: "@/components/formPrimitives",
message:
"Use '@/components/ui' primitives; formPrimitives has been removed.",
},
{
name: "@/components/ui/buttonPrimitives",
message:
"Use '@/components/ui' button exports; buttonPrimitives has been removed.",
},
{
name: "@headlessui/react",
importNames: ["Button", "Input"],
message:
"Use app UI Button/Input primitives; Headless UI should be reserved for structural primitives such as Menu, Listbox, Popover, Dialog, and Transition.",
},
];
const RESTRICTED_APP_CLASS_PATTERNS = [
{
selector: "Literal[value=/(bg|text|border|ring)-(gray|slate|zinc|neutral|stone|red|orange|yellow|amber|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\\d/]",
message:
"Use semantic design-system tokens instead of literal Tailwind palette color classes in app surfaces.",
},
{
selector: "TemplateElement[value.raw=/(bg|text|border|ring)-(gray|slate|zinc|neutral|stone|red|orange|yellow|amber|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\\d/]",
message:
"Use semantic design-system tokens instead of literal Tailwind palette color classes in app surfaces.",
},
{
selector: "Literal[value=/transition-all/]",
message:
"Use scoped transition utilities; transition-all is banned by the app design system.",
},
{
selector: "TemplateElement[value.raw=/transition-all/]",
message:
"Use scoped transition utilities; transition-all is banned by the app design system.",
},
{
selector: "Literal[value=/(bg|text|border|from|via|to)-(accent|background|foreground|base|offbase|muted|secondary-accent|surface|surface-solid|surface-sunken|line|line-soft|line-strong|soft|faint|accent-wash|accent-line|accent-strong|danger|danger-wash)\\/\\d/]",
message:
"Do not use Tailwind alpha modifiers on theme-token colors; use dedicated wash/semantic tokens.",
},
{
selector: "TemplateElement[value.raw=/(bg|text|border|from|via|to)-(accent|background|foreground|base|offbase|muted|secondary-accent|surface|surface-solid|surface-sunken|line|line-soft|line-strong|soft|faint|accent-wash|accent-line|accent-strong|danger|danger-wash)\\/\\d/]",
message:
"Do not use Tailwind alpha modifiers on theme-token colors; use dedicated wash/semantic tokens.",
},
{
selector: "Literal[value=/rounded-\\[[^\\]]*px\\]/]",
message:
"Use the app radius scale instead of arbitrary rounded-[…px] utilities.",
},
{
selector: "TemplateElement[value.raw=/rounded-\\[[^\\]]*px\\]/]",
message:
"Use the app radius scale instead of arbitrary rounded-[…px] utilities.",
},
{
selector: "Literal[value=/duration-\\[[^\\]]*ms\\]/]",
message:
"Use the shared motion duration tokens instead of arbitrary duration-[…ms] utilities.",
},
{
selector: "TemplateElement[value.raw=/duration-\\[[^\\]]*ms\\]/]",
message:
"Use the shared motion duration tokens instead of arbitrary duration-[…ms] utilities.",
},
];
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"no-restricted-imports": [
"error",
{
patterns: COMPUTE_WORKER_IMPORT_PATTERNS,
},
],
},
},
{
files: APP_DESIGN_SYSTEM_FILES,
rules: {
"no-restricted-syntax": ["error", ...RESTRICTED_APP_CLASS_PATTERNS],
},
},
{
files: UI_ARCHITECTURE_FILES,
rules: {
"no-restricted-imports": [
"error",
{
paths: UI_ARCHITECTURE_IMPORT_PATHS,
patterns: COMPUTE_WORKER_IMPORT_PATTERNS,
},
],
},
},
{
files: ["src/app/api/**/*.ts", "src/lib/server/**/*.ts"],
rules: {
"no-console": "error",
"no-restricted-syntax": [
"error",
{
selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.length<2]`,
message:
"Server logger calls must pass context + message: logger.<level>({ event, ...ctx }, 'message').",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.0.type!='ObjectExpression']`,
message:
"Server logger first argument must be an object with an event field.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.0.type='Literal']`,
message:
"Server logger first argument must be an object with an event field, not a string literal.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.0.type='TemplateLiteral']`,
message:
"Server logger first argument must be an object with an event field, not a template string.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.0.type='ObjectExpression']:not(:has(Property[key.name='event']))`,
message:
"Server logger context object must include an event field.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.1.type!='Literal'][arguments.1.type!='TemplateLiteral']`,
message:
"Server logger second argument must be a message string (literal or template literal).",
},
{
selector: `${STATIC_LOGGER_CALL_SELECTOR}[callee.property.name='error']${LOGGER_RECEIVER_SELECTOR}[arguments.0.type='ObjectExpression']:not(:has(ObjectExpression > Property[key.name='error'])):not(:has(ObjectExpression > SpreadElement))`,
message:
"Error-level server logger calls must include nested `error` payload (prefer `error: errorToLog(...)`).",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='detail']`,
message:
"Do not use top-level `detail` in server logger context; keep throwable text under `error.message`.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='errorCode']`,
message:
"Do not use top-level `errorCode` in server logger context; classify failures under nested `error.code`.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='err']`,
message:
"Use `error` (typically from errorToLog(...)) instead of `err` in server logs.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='error'][value.type='Literal']`,
message:
"Server logger `error` must be a structured object (prefer `errorToLog(...)`), not a literal.",
},
{
selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='error'][value.type='TemplateLiteral']`,
message:
"Server logger `error` must be a structured object (prefer `errorToLog(...)`), not template text.",
},
{
selector: `CatchClause ReturnStatement > ${NEXT_RESPONSE_ERROR_JSON_SELECTOR}[arguments.1.type='ObjectExpression']:has(Property[key.name='status'][value.value=500])`,
message:
"Use shared error response helpers (e.g. errorResponse(...)) for terminal 500 route failures instead of direct NextResponse.json({ error }).",
},
],
},
},
];
export default eslintConfig;