chore: cleanup project structure and resolve client type errors

- resolve all 69 client type-checking errors for stricter TS compliance
- remove obsolete type-check baseline scripts and files
- overhaul project root with updated README.md and new DEVELOPMENT.md
- migrate to unified baseline migration strategy and cleanup docker configs
- remove redundant production and dev-specific shell scripts
This commit is contained in:
neon
2026-03-25 07:14:51 -06:00
parent 4d58fbdfc6
commit 1418957b6f
51 changed files with 379 additions and 997 deletions
-1
View File
@@ -3,7 +3,6 @@
**/dist
**/*.log
.env
.env.prod
client/.env
server/.env
docker/postgres/data
+42
View File
@@ -0,0 +1,42 @@
# Immutable image reference built by CI (use a sha-* tag or @sha256 digest)
APP_IMAGE=ghcr.io/your-org/projectx:sha-<git-sha>
# App publish port on host
APP_PORT=3000
# Database credentials
POSTGRES_USER=projectx
POSTGRES_PASSWORD=change-this-password
POSTGRES_DB=projectx
# App secrets
JWT_SECRET=change-this-to-a-long-random-secret
# Required for the initial /auth/setup endpoint (x-setup-token header)
SETUP_BOOTSTRAP_TOKEN=change-this-to-a-long-random-secret
# External/public URL used by emails and Kobo endpoints
APP_URL=https://projectx.example.com
# --- Optional overrides (uncomment to set) ---
# If you already run your own PostgreSQL, set DATABASE_URL and remove the
# postgres service from docker-compose.yml. Your instance must have the
# uuid-ossp, pg_trgm, and vector (pgvector) extensions available.
# DATABASE_URL=postgres://user:password@yourhost:5432/projectx
# Or set individual parts (POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB)
# JWT token lifetimes
# JWT_EXPIRES_IN=15m
# JWT_REFRESH_EXPIRES_IN=7d
# CORS origin for the frontend when served from a different domain than APP_URL
# CLIENT_URL=https://app.example.com
# Staging directory (defaults to {BOOKS_PATH}/staging inside the container)
# STAGING_PATH=/staging
# AES-256 key for encrypting stored email credentials (generate: openssl rand -hex 32)
# EMAIL_ENCRYPTION_KEY=
# Log level (default: info). Set to debug for verbose output.
# LOG_LEVEL=info
-31
View File
@@ -1,31 +0,0 @@
# Immutable image reference built by CI (use a sha-* tag or @sha256 digest)
APP_IMAGE=ghcr.io/your-org/projectx:sha-<git-sha>
# App publish port on host
APP_PORT=3000
# Database credentials
POSTGRES_USER=projectx
POSTGRES_PASSWORD=change-this-password
POSTGRES_DB=projectx
# App secrets
JWT_SECRET=change-this-to-a-long-random-secret
EMAIL_ENCRYPTION_KEY=
# External/public URL used by emails and Kobo endpoints
APP_URL=https://projectx.example.com
# Optional client URL override
CLIENT_URL=https://projectx.example.com
# Optional SMTP config
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@projectx.local
# Optional API keys
GOOGLE_BOOKS_API_KEY=
+2 -2
View File
@@ -62,7 +62,7 @@ jobs:
- name: Compute changed scopes
id: changes
if: github.event_name != 'workflow_dispatch'
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
with:
filters: |
server:
@@ -152,7 +152,7 @@ jobs:
pnpm --filter server type-check
else
pnpm --filter @projectx/types build
pnpm run typecheck:client:baseline
pnpm --filter client type-check
fi
- name: Prepare test report directory
+1 -1
View File
@@ -2,10 +2,10 @@ node_modules/
dist/
.env
.env.local
.env.prod
client/.env
server/.env
*.local
data
# OS
.DS_Store
Regular → Executable
+1 -1
View File
@@ -1 +1 @@
pnpm run quick
pnpm run verify:fast
+182
View File
@@ -0,0 +1,182 @@
# Development Guide
## Prerequisites
- Node.js >= 24
- pnpm >= 9
- Docker
---
## First-time Setup
```bash
pnpm setup
```
This creates `server/.env` from the example, installs all dependencies, starts PostgreSQL, runs migrations, and seeds baseline data.
If you prefer to do it manually:
```bash
pnpm run db:up # start PostgreSQL
cp server/.env.example server/.env # first time only
pnpm install
pnpm run db:migrate
pnpm run db:seed
```
---
## Running the App
```bash
pnpm dev
```
Starts the server and client concurrently with hot reload.
| Service | URL |
| ------- | ------------------------- |
| API | http://localhost:3000/api |
| Client | http://localhost:5173 |
If PostgreSQL stopped (e.g. after a machine restart):
```bash
pnpm run db:up # then pnpm dev
```
---
## Database Workflows
### Apply pending migrations
After pulling changes that include new migrations:
```bash
pnpm run db:migrate
```
### Add a schema change
1. Edit the relevant file in `server/src/db/schema/`
2. Generate a migration:
```bash
cd server && pnpm db:generate add-book-tags
```
This creates a new SQL file in `server/src/db/migrations/`. Review it before applying.
3. Apply it:
```bash
pnpm run db:migrate
```
> **Pre-launch rule:** Before the app ships, maintain a single baseline migration (`0000_*.sql`) rather than stacking incremental ones. When adding a schema change pre-launch:
>
> ```bash
> rm -rf server/src/db/migrations
> docker compose -f docker-compose.dev.yml down -v
> pnpm run db:up
> cd server && pnpm db:generate baseline
> pnpm run db:migrate
> ```
>
> After go-live, never wipe migrations. Every change gets its own incremental migration.
### Full database reset
Wipes all data, re-runs migrations, re-seeds, and clears generated files (covers, author images):
```bash
pnpm run db:reset
```
Use this when:
- Migrations are in a broken or inconsistent state
- A migration requires starting from scratch (e.g. column type change with no clean upgrade path)
- You want a known-clean local state
### Inspect the database
```bash
cd server && pnpm db:studio
```
Opens Drizzle Studio in your browser for browsing and editing tables directly.
---
## Testing
```bash
pnpm run test # all tests (server + client)
pnpm run test:server # server unit tests only
pnpm run test:client # client unit tests only
pnpm run test:e2e:smoke # e2e smoke suite (requires running DB)
```
Watch mode while working on a specific area:
```bash
cd server && pnpm test:watch
```
---
## Code Quality
Run before pushing:
```bash
pnpm run verify:fast # lint + typecheck + tests (same as pre-push hook)
pnpm run verify # above + e2e smoke (run before opening a PR)
```
Individual checks:
```bash
pnpm run lint:check # check for lint errors
pnpm run lint:fix # auto-fix lint errors
pnpm run typecheck # server + client baseline typecheck
pnpm run typecheck:full # server + full client typecheck (slower)
```
Format:
```bash
cd server && npx prettier --write .
cd client && npx prettier --write .
```
---
## Common Scenarios
### Pulled changes that include a new migration
```bash
pnpm run db:migrate
```
### Pulled changes that changed the DB schema significantly and your local state is stale
```bash
pnpm run db:reset
```
### Something is wrong and you want a completely clean slate
```bash
pnpm run db:reset
```
If that is not enough (e.g. Docker volume is corrupted):
```bash
docker compose -f docker-compose.dev.yml down -v
pnpm run db:up
pnpm run db:migrate
pnpm run db:seed
```
+1 -1
View File
@@ -44,7 +44,7 @@ COPY --from=server-builder --chown=node:node /deploy ./
COPY --from=client-builder --chown=node:node /app/client/dist ./public
COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh
RUN chmod +x /app/entrypoint.sh && mkdir -p /books /tmp && chown -R node:node /books /tmp
RUN chmod +x /app/entrypoint.sh && mkdir -p /books /staging /data /tmp && chown -R node:node /books /staging /data /tmp
USER node
-16
View File
@@ -1,16 +0,0 @@
FROM node:24-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY client/package.json ./client/
COPY server/package.json ./server/
COPY packages/types/package.json ./packages/types/
RUN pnpm install --frozen-lockfile
COPY . .
EXPOSE 3000 5173
CMD ["pnpm", "dev"]
-8
View File
@@ -1,8 +0,0 @@
FROM node:24-alpine
WORKDIR /app
RUN npm install -g pnpm
COPY package.json package-lock.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
+2 -4
View File
@@ -101,15 +101,14 @@ onMounted(fetchPage)
<th class="px-4 py-3 text-left font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Description</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground whitespace-nowrap hidden md:table-cell">IP</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground whitespace-nowrap hidden md:table-cell">Country</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr v-if="loading">
<td colspan="6" class="px-4 py-8 text-center text-sm text-muted-foreground">Loading...</td>
<td colspan="5" class="px-4 py-8 text-center text-sm text-muted-foreground">Loading...</td>
</tr>
<tr v-else-if="entries.length === 0">
<td colspan="6" class="px-4 py-8 text-center text-sm text-muted-foreground">No audit logs found</td>
<td colspan="5" class="px-4 py-8 text-center text-sm text-muted-foreground">No audit logs found</td>
</tr>
<tr
v-else
@@ -130,7 +129,6 @@ onMounted(fetchPage)
</td>
<td class="px-4 py-2.5 text-muted-foreground max-w-xs truncate">{{ entry.description }}</td>
<td class="px-4 py-2.5 text-muted-foreground font-mono text-xs hidden md:table-cell">{{ entry.ip ?? '-' }}</td>
<td class="px-4 py-2.5 text-muted-foreground text-xs hidden md:table-cell">{{ entry.countryCode ?? '-' }}</td>
</tr>
</tbody>
</table>
@@ -59,7 +59,8 @@ watch(representativeCandidates, (list) => {
})
const activeCandidate = computed(
() => representativeCandidates.value.find((c) => c.provider === activeProvider.value) ?? representativeCandidates.value[0],
() =>
representativeCandidates.value.find((c) => c.provider === activeProvider.value) ?? representativeCandidates.value[0] ?? props.initialCandidate,
)
const { fields, picksPerProvider, toggleField, pickFieldFromProvider, clearPicksForProvider, copyAll, copyMissing, buildPatch, hasCopied } =
@@ -6,6 +6,7 @@ import { useMetadataDiff } from '../useMetadataDiff'
describe('useMetadataDiff', () => {
const mockCurrent: MetadataSource = {
title: 'Original Title',
subtitle: 'Original Subtitle',
authors: ['Original Author'],
genres: ['Genre 1'],
description: 'Original Description',
@@ -17,6 +18,9 @@ describe('useMetadataDiff', () => {
seriesIndex: 1,
isbn10: '1234567890',
isbn13: '1234567890123',
narrators: [],
durationSeconds: null,
abridged: null,
}
const mockCandidate1: MetadataCandidate = {
@@ -37,8 +41,8 @@ describe('useMetadataDiff', () => {
}
const providers = [
{ key: 'google' as MetadataProviderKey, label: 'Google Books' },
{ key: 'goodreads' as MetadataProviderKey, label: 'Goodreads' },
{ key: 'google' as MetadataProviderKey, label: 'Google Books', identifiable: true },
{ key: 'goodreads' as MetadataProviderKey, label: 'Goodreads', identifiable: true },
]
it('initializes with fields from active provider', () => {
@@ -42,8 +42,10 @@ function onDrop(index: number) {
if (draggedIndex.value === null || draggedIndex.value === index) return
const items = [...draft.value]
const [moved] = items.splice(draggedIndex.value, 1)
if (moved) {
items.splice(index, 0, moved)
draft.value = items
}
draggedIndex.value = null
dragOverIndex.value = null
}
@@ -27,7 +27,7 @@ export function useFoliateInput(
function handleTouchStart(e: TouchEvent) {
if (e.touches.length !== 1) return
const touch = e.touches[0]
const touch = e.touches[0]!
touchStartX = touch.clientX
touchStartY = touch.clientY
touchStartTime = Date.now()
@@ -39,7 +39,7 @@ export function useFoliateInput(
function handleTouchMove(e: TouchEvent, doc: Document) {
if (e.touches.length !== 1) return
const touch = e.touches[0]
const touch = e.touches[0]!
const deltaX = Math.abs(touch.clientX - touchStartX)
const deltaY = Math.abs(touch.clientY - touchStartY)
const selection = doc.defaultView?.getSelection()
@@ -67,7 +67,7 @@ export function useFoliateInput(
}
if (!isTextSelectionInProgress && e.changedTouches.length === 1) {
const touch = e.changedTouches[0]
const touch = e.changedTouches[0]!
const deltaX = touch.clientX - touchStartX
const deltaY = Math.abs(touch.clientY - touchStartY)
@@ -1,6 +1,6 @@
import { computed, ref } from 'vue'
import { themes } from '../constants/themes'
import type { ThemeMode } from '../constants/themes'
import type { Theme, ThemeMode } from '../constants/themes'
import type { FoliateRenderer } from './useFoliate'
export interface ReaderState {
@@ -62,7 +62,7 @@ export function useReaderState() {
flow: flow.value,
}))
const currentTheme = computed(() => themes.find((t) => t.name === themeName.value) ?? themes[0])
const currentTheme = computed<Theme>(() => themes.find((t) => t.name === themeName.value) ?? themes[0]!)
const activeMode = computed<ThemeMode>(() => {
const theme = currentTheme.value
@@ -209,7 +209,7 @@ export function useReaderState() {
function applyToRenderer(renderer: FoliateRenderer): void {
if (!renderer) return
const s = state.value
renderer.setAttribute('max-column-count', s.maxColumnCount)
renderer.setAttribute('max-column-count', String(s.maxColumnCount))
renderer.setAttribute('gap', `${s.gap * 100}%`)
renderer.setAttribute('max-inline-size', `${s.maxInlineSize}px`)
renderer.setAttribute('max-block-size', `${s.maxBlockSize}px`)
+1 -1
View File
@@ -1,5 +1,5 @@
export function stripFragment(href: string): string {
return href.split('#')[0]
return href.split('#')[0]!
}
export function formatDate(dateStr: string): string {
@@ -28,8 +28,8 @@ export function usePdfFind(textLayerMap: Ref<Map<number, HTMLElement>>) {
}
function isWordBoundary(text: string, start: number, len: number): boolean {
const before = start === 0 ? ' ' : text[start - 1]
const after = start + len >= text.length ? ' ' : text[start + len]
const before = start === 0 ? ' ' : (text[start - 1] ?? ' ')
const after = start + len >= text.length ? ' ' : (text[start + len] ?? ' ')
return /\W/.test(before) && /\W/.test(after)
}
@@ -89,7 +89,7 @@ export function usePdfRenderer(
if (typeof item !== 'object' || item === null || !('str' in item)) continue
const { str, transform, height } = item as PdfTextItem
if (!str.trim()) continue
const [a, b, , , e, f] = transform
const [a, b, , , e, f] = transform as [number, number, number, number, number, number]
const span = document.createElement('span')
span.textContent = str
span.style.cssText =
@@ -580,11 +580,13 @@ onMounted(() => {
<div class="flex-1 min-h-0">
<MetadataDiffPanel
:current="currentSource"
:candidate="selectedCandidate"
:candidates="diffSource === 'fetched' ? [selectedCandidate] : filteredResults"
:initial-candidate="selectedCandidate"
:filtered-results="diffSource === 'fetched' ? [selectedCandidate] : filteredResults"
:providers="providers"
:back-label="diffSource === 'fetched' ? 'Back' : 'Results'"
:current-cover-url="currentStagingCoverUrl"
:field-sources="diffSource === 'fetched' ? (file.fetchedMetadataSources ?? undefined) : undefined"
:provider-ids="(file.fetchedMetadataSources as any) ?? undefined"
@back="backFromDiff"
@apply="handleApply"
/>
+5 -3
View File
@@ -30,11 +30,13 @@ async function attemptRefresh(): Promise<void> {
}
export async function api(input: RequestInfo | URL, init?: RequestInit & { _isRetry?: boolean }): Promise<Response> {
const res = await rawFetch(input, init)
const isRetry = init?._isRetry
const { _isRetry: _, ...rest } = init ?? {}
const res = await rawFetch(input, rest)
if (res.status !== 401) return res
if (init?._isRetry) {
if (isRetry) {
_onAuthFailure?.()
throw new Error('Session expired')
}
@@ -52,5 +54,5 @@ export async function api(input: RequestInfo | URL, init?: RequestInit & { _isRe
throw new Error('Session expired')
}
return rawFetch(input, { ...init, _isRetry: true })
return api(input, { ...rest, _isRetry: true })
}
-53
View File
@@ -1,53 +0,0 @@
# Client typecheck baseline
# Auto-generated by: pnpm run typecheck:client:baseline:update
# One diagnostic per line. Do not edit manually unless necessary.
src/features/book/components/detail/tabs/EditMetadataTab.vue(394,14): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(400,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(404,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(411,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(415,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(421,14): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(427,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(431,16): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/EditMetadataTab.vue(437,14): error TS2345: Argument of type '{ modelValue: string[]; }' is not assignable to parameter of type '{ readonly modelValue: string[]; readonly placeholder?: string | undefined; readonly searchFn: (q: string) => Promise<string[]>; readonly "onUpdate:modelValue"?: ((args_0: string[]) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(115,20): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(117,19): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(124,69): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(126,105): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/components/detail/tabs/MetadataDiffPanel.vue(127,35): error TS18048: '__VLS_ctx.activeCandidate' is possibly 'undefined'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(107,90): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(117,140): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(131,99): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(151,81): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(161,81): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(47,81): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(58,81): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(68,94): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(7,9): error TS2739: Type '{ title: string; authors: string[]; genres: string[]; description: string; publisher: string; publishedYear: number; language: string; pageCount: number; seriesName: string; seriesIndex: number; isbn10: string; isbn13: string; }' is missing the following properties from type 'MetadataSource': subtitle, narrators, durationSeconds, abridged
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(79,104): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/book/composables/__tests__/useMetadataDiff.spec.ts(91,121): error TS2345: Argument of type '{ key: MetadataProviderKey; label: string; }[]' is not assignable to parameter of type 'MaybeRefOrGetter<MetadataProviderInfo[]>'.
src/features/dashboard/components/DashboardSettingsSheet.vue(45,26): error TS2345: Argument of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to parameter of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/reader/epub/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(43,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(44,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(71,22): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(72,31): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(87,45): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(92,39): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(95,27): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(212,47): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
src/features/reader/epub/composables/useReaderState.ts(69,27): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(69,40): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
src/features/reader/pdf/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
src/features/reader/pdf/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
src/features/reader/pdf/composables/usePdfRenderer.ts(97,17): error TS18048: 'e' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(98,34): error TS18048: 'f' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,31): error TS18048: 'a' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,50): error TS18048: 'b' is possibly 'undefined'.
src/features/settings/metadata-preferences/components/ProviderChipList.vue(56,34): error TS2345: Argument of type 'MetadataProviderKey | undefined' is not assignable to parameter of type 'MetadataProviderKey'.
src/features/staging/components/StagingFileSheet.vue(581,12): error TS2345: Argument of type '{ current: MetadataSource; candidate: { provider: MetadataProviderKey; providerId: string; title: string; subtitle?: string | undefined; authors?: string[] | undefined; description?: string | undefined; publisher?: string | undefined; ... 15 more ...; comicMetadata?: { ...; } | undefined; }; ... 5 more ...; onBack: ...' is not assignable to parameter of type '{ readonly current: MetadataSource; readonly candidates: MetadataCandidate[]; readonly initialCandidate: MetadataCandidate; ... 6 more ...; readonly onApply?: ((args_0: { ...; }) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps & Record<...>'.
src/lib/api.ts(55,37): error TS2353: Object literal may only specify known properties, and '_isRetry' does not exist in type 'RequestInit'.
-161
View File
@@ -1,161 +0,0 @@
> client@0.0.0 type-check /Users/developer/Workspace/projectx/client
> vue-tsc --build
src/features/book/components/detail/BookDetailHeader.vue(22,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
Type 'undefined' is not assignable to type 'number | null'.
src/features/book/components/detail/BookDetailHeader.vue(23,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
Type 'undefined' is not assignable to type 'number | null'.
src/features/dashboard/components/DashboardSettingsSheet.vue(45,26): error TS2345: Argument of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to parameter of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(80,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(80,23): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(87,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(87,19): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/library/components/LibraryCreatorScanner.vue(36,36): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/library/composables/useBookUpload.ts(114,5): error TS2304: Cannot find name 'processQueue'.
src/features/reader/cbz/CbzReaderView.vue(95,17): error TS2532: Object is possibly 'undefined'.
src/features/reader/cbz/CbzReaderView.vue(99,14): error TS2532: Object is possibly 'undefined'.
src/features/reader/epub/components/NoteDialog.vue(14,25): error TS2345: Argument of type '"update:modelValue"' is not assignable to parameter of type '"save"'.
src/features/reader/epub/components/ReaderSidebar.vue(7,10): error TS2300: Duplicate identifier 'stripFragment'.
src/features/reader/epub/components/ReaderSidebar.vue(128,10): error TS2300: Duplicate identifier 'stripFragment'.
src/features/reader/epub/components/ReaderSidebar.vue(130,7): error TS7022: 'TocList' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
src/features/reader/epub/components/ReaderSidebar.vue(144,12): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
src/features/reader/epub/components/ReaderSidebar.vue(148,25): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
src/features/reader/epub/composables/useFoliate.ts(112,36): error TS2352: Conversion of type 'Window & typeof globalThis' to type 'Record<string, unknown>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
Index signature for type 'string' is missing in type 'Window & typeof globalThis'.
src/features/reader/epub/composables/useFoliateAnnotations.ts(81,48): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateAnnotations.ts(87,50): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateAnnotations.ts(93,48): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateAnnotations.ts(99,52): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(43,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(44,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(71,22): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(72,31): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(87,45): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(92,39): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(95,27): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(69,27): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(69,40): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(212,47): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
src/features/reader/epub/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/reader/pdf/components/PdfToolbar.vue(205,93): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type '"applyZoomPreset"' is not assignable to parameter of type '"update:cursorTool"'.
src/features/reader/pdf/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/reader/pdf/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/reader/pdf/composables/usePdfRenderer.ts(97,17): error TS18048: 'e' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(98,34): error TS18048: 'f' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,31): error TS18048: 'a' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,50): error TS18048: 'b' is possibly 'undefined'.
src/features/reader/ReaderView.vue(149,15): error TS2345: Argument of type 'unknown[]' is not assignable to parameter of type 'RawTocItem[]'.
Type 'unknown' is not assignable to type 'RawTocItem'.
src/features/reader/ReaderView.vue(241,12): error TS2345: Argument of type '{}' is not assignable to parameter of type 'FoliateView'.
Property 'search' is missing in type '{}' but required in type 'FoliateView'.
src/features/reader/ReaderView.vue(253,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'FoliateView | null'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,25): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,21): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/ProviderChipList.vue(56,34): error TS2345: Argument of type 'MetadataProviderKey | undefined' is not assignable to parameter of type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/lib/api.ts(55,37): error TS2353: Object literal may only specify known properties, and '_isRetry' does not exist in type 'RequestInit'.
src/features/book/components/detail/BookDetailHeader.vue(22,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
Type 'undefined' is not assignable to type 'number | null'.
src/features/book/components/detail/BookDetailHeader.vue(23,5): error TS2322: Type 'number | null | undefined' is not assignable to type 'number | null'.
Type 'undefined' is not assignable to type 'number | null'.
src/features/dashboard/components/DashboardSettingsSheet.vue(45,26): error TS2345: Argument of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to parameter of type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(80,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(80,23): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(87,5): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/dashboard/components/DashboardSettingsSheet.vue(87,19): error TS2322: Type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; } | undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
Type 'undefined' is not assignable to type '{ id: string; type: ScrollerType; label: string; enabled: boolean; order: number; limit: number; lensId?: number | undefined; }'.
src/features/library/components/LibraryCreatorScanner.vue(36,36): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/library/composables/useBookUpload.ts(114,5): error TS2304: Cannot find name 'processQueue'.
src/features/reader/cbz/CbzReaderView.vue(95,17): error TS2532: Object is possibly 'undefined'.
src/features/reader/cbz/CbzReaderView.vue(99,14): error TS2532: Object is possibly 'undefined'.
src/features/reader/epub/components/NoteDialog.vue(14,25): error TS2345: Argument of type '"update:modelValue"' is not assignable to parameter of type '"save"'.
src/features/reader/epub/components/ReaderSidebar.vue(7,10): error TS2300: Duplicate identifier 'stripFragment'.
src/features/reader/epub/components/ReaderSidebar.vue(128,10): error TS2300: Duplicate identifier 'stripFragment'.
src/features/reader/epub/components/ReaderSidebar.vue(130,7): error TS7022: 'TocList' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
src/features/reader/epub/components/ReaderSidebar.vue(144,12): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
src/features/reader/epub/components/ReaderSidebar.vue(148,25): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
src/features/reader/epub/composables/useFoliate.ts(112,36): error TS2352: Conversion of type 'Window & typeof globalThis' to type 'Record<string, unknown>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
Index signature for type 'string' is missing in type 'Window & typeof globalThis'.
src/features/reader/epub/composables/useFoliateAnnotations.ts(81,48): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateAnnotations.ts(87,50): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateAnnotations.ts(93,48): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateAnnotations.ts(99,52): error TS2349: This expression is not callable.
Type '{}' has no call signatures.
src/features/reader/epub/composables/useFoliateInput.ts(31,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(32,19): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(43,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(44,29): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(71,22): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(72,31): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(87,45): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(92,39): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useFoliateInput.ts(95,27): error TS18048: 'touch' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(69,27): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(69,40): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(76,23): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(77,22): error TS18048: 'theme' is possibly 'undefined'.
src/features/reader/epub/composables/useReaderState.ts(212,47): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
src/features/reader/epub/utils.ts(2,3): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/reader/pdf/components/PdfToolbar.vue(205,93): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type '"applyZoomPreset"' is not assignable to parameter of type '"update:cursorTool"'.
src/features/reader/pdf/composables/usePdfFind.ts(33,22): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/reader/pdf/composables/usePdfFind.ts(33,43): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
src/features/reader/pdf/composables/usePdfRenderer.ts(97,17): error TS18048: 'e' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(98,34): error TS18048: 'f' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,31): error TS18048: 'a' is possibly 'undefined'.
src/features/reader/pdf/composables/usePdfRenderer.ts(99,50): error TS18048: 'b' is possibly 'undefined'.
src/features/reader/ReaderView.vue(149,15): error TS2345: Argument of type 'unknown[]' is not assignable to parameter of type 'RawTocItem[]'.
Type 'unknown' is not assignable to type 'RawTocItem'.
src/features/reader/ReaderView.vue(241,12): error TS2345: Argument of type '{}' is not assignable to parameter of type 'FoliateView'.
Property 'search' is missing in type '{}' but required in type 'FoliateView'.
src/features/reader/ReaderView.vue(253,15): error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'FoliateView | null'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(57,25): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,5): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/FieldConfigSheet.vue(65,21): error TS2322: Type 'MetadataProviderKey | undefined' is not assignable to type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/features/settings/metadata-preferences/components/ProviderChipList.vue(56,34): error TS2345: Argument of type 'MetadataProviderKey | undefined' is not assignable to parameter of type 'MetadataProviderKey'.
Type 'undefined' is not assignable to type 'MetadataProviderKey'.
src/lib/api.ts(55,37): error TS2353: Object literal may only specify known properties, and '_isRetry' does not exist in type 'RequestInit'.
 ELIFECYCLE  Command failed with exit code 2.
+24
View File
@@ -0,0 +1,24 @@
name: projectx-dev
services:
postgres:
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-projectx}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-projectx}
POSTGRES_DB: ${POSTGRES_DB:-projectx}
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U projectx"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
-106
View File
@@ -1,106 +0,0 @@
name: projectx
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
postgres:
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER?POSTGRES_USER is required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB?POSTGRES_DB is required}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER?POSTGRES_USER is required} -d ${POSTGRES_DB?POSTGRES_DB is required}",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
logging: *default-logging
migrate:
image: ${APP_IMAGE?APP_IMAGE is required}
restart: "no"
init: true
environment:
DATABASE_URL: postgres://${POSTGRES_USER?POSTGRES_USER is required}:${POSTGRES_PASSWORD?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB?POSTGRES_DB is required}
NODE_ENV: production
depends_on:
postgres:
condition: service_healthy
command: ["node", "dist/scripts/migrate.js"]
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
logging: *default-logging
app:
image: ${APP_IMAGE?APP_IMAGE is required}
restart: unless-stopped
init: true
ports:
- "${APP_PORT:-3000}:3000"
environment:
DATABASE_URL: postgres://${POSTGRES_USER?POSTGRES_USER is required}:${POSTGRES_PASSWORD?POSTGRES_PASSWORD is required}@postgres:5432/${POSTGRES_DB?POSTGRES_DB is required}
JWT_SECRET: ${JWT_SECRET?JWT_SECRET is required}
APP_URL: ${APP_URL?APP_URL is required}
NODE_ENV: production
BOOKS_PATH: /books
STAGING_PATH: /books/staging
PORT: 3000
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-15m}
JWT_REFRESH_EXPIRES_IN: ${JWT_REFRESH_EXPIRES_IN:-7d}
CLIENT_URL: ${CLIENT_URL:-http://localhost:5173}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-noreply@projectx.local}
EMAIL_ENCRYPTION_KEY: ${EMAIL_ENCRYPTION_KEY:-}
GOOGLE_BOOKS_API_KEY: ${GOOGLE_BOOKS_API_KEY:-}
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
volumes:
- books_data:/books
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
stop_grace_period: 30s
healthcheck:
test:
[
"CMD-SHELL",
'node -e "fetch(''http://127.0.0.1:3000/api/v1/health'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"',
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
logging: *default-logging
volumes:
postgres_data:
books_data:
+52 -16
View File
@@ -1,25 +1,61 @@
name: projectx-dev
name: projectx
services:
postgres:
image: pgvector/pgvector:pg16
image: pgvector/pgvector:pg16@sha256:7d400e340efb42f4d8c9c12c6427adb253f726881a9985d2a471bf0eed824dff
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-projectx}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-projectx}
POSTGRES_DB: ${POSTGRES_DB:-projectx}
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "5432:5432"
POSTGRES_USER: ${POSTGRES_USER?required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required}
POSTGRES_DB: ${POSTGRES_DB?required}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U projectx"]
interval: 5s
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER?required} -d ${POSTGRES_DB?required}",
]
interval: 10s
timeout: 5s
retries: 5
retries: 10
start_period: 20s
volumes:
postgres_data:
app:
image: ${APP_IMAGE?required}
restart: unless-stopped
init: true
ports:
- "${APP_PORT:-3000}:3000"
environment:
POSTGRES_USER: ${POSTGRES_USER?required}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required}
POSTGRES_DB: ${POSTGRES_DB?required}
JWT_SECRET: ${JWT_SECRET?required}
SETUP_BOOTSTRAP_TOKEN: ${SETUP_BOOTSTRAP_TOKEN?required}
APP_URL: ${APP_URL?required}
depends_on:
postgres:
condition: service_healthy
volumes:
- ./books:/books
- ./staging:/staging
- ./data:/data
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
stop_grace_period: 30s
healthcheck:
test:
[
"CMD-SHELL",
'node -e "fetch(''http://127.0.0.1:''+(process.env.PORT||3000)+''/api/v1/health'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"',
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
-84
View File
@@ -1,84 +0,0 @@
# Production Deployment (Docker Compose)
This project's production Compose stack is intentionally separate from local development.
## 1) Configure production environment
Create `.env.prod` from the example and set real values:
```bash
cp .env.prod.example .env.prod
```
Required fields:
- `APP_IMAGE` (immutable tag or digest from CI, for example `ghcr.io/<owner>/<repo>:sha-<commit>`)
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
- `POSTGRES_DB`
- `JWT_SECRET`
- `APP_URL`
## 2) Deploy
```bash
docker compose --env-file .env.prod -f docker-compose.prod.yml pull
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --wait
```
What happens:
- `postgres` starts with `pgvector` enabled via init SQL.
- `migrate` runs once (`node dist/scripts/migrate.js`) and must complete successfully.
- `app` starts only after DB health is `healthy` and migration service exits successfully.
## 3) Roll forward
1. Update `APP_IMAGE` in `.env.prod` to the new immutable image.
2. Pull + restart:
```bash
docker compose --env-file .env.prod -f docker-compose.prod.yml pull
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --wait
```
## 4) Backup strategy
Create a compressed custom-format Postgres backup:
```bash
pnpm db:backup:prod
```
By default this writes to `local/backups/`.
Recommended cadence:
- Daily backups
- Keep at least 7 daily + 4 weekly + 3 monthly snapshots
- Store a copy off-host
## 5) Restore strategy
Restore into the live DB:
```bash
pnpm db:restore:prod -- local/backups/<file>.dump
```
Run a non-destructive restore test into a temporary DB:
```bash
pnpm db:restore:test:prod -- local/backups/<file>.dump
```
Run the restore test at least monthly so backup integrity is continuously verified.
## 6) CI image pipeline
Workflow: `.github/workflows/container-image.yml`
On `main` and tag pushes it:
- builds the Docker image
- pushes immutable tags to GHCR (`sha-*`)
+4 -14
View File
@@ -2,26 +2,19 @@
"name": "projectx",
"private": true,
"scripts": {
"help": "node ./scripts/help.mjs",
"guide": "node ./scripts/help.mjs",
"setup": "bash ./scripts/setup.sh",
"doctor": "bash ./scripts/doctor.sh",
"dev": "concurrently --names \"types,server,client\" --prefix-colors \"cyan,blue,green\" \"pnpm --filter @projectx/types dev\" \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "cd client && npm run dev -- --host",
"dev:server": "cd server && npm run start:dev",
"quick": "pnpm run verify:fast",
"verify:fast": "pnpm run lint:check && pnpm run typecheck && pnpm run test",
"verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test && pnpm run test:e2e:smoke",
"verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test && pnpm run test:e2e:smoke",
"build:client": "pnpm --filter client build",
"build:server": "pnpm --filter server build",
"typecheck": "pnpm run typecheck:server && pnpm run typecheck:client:baseline",
"typecheck": "pnpm run typecheck:server && pnpm run typecheck:client",
"typecheck:full": "pnpm run typecheck:server && pnpm run typecheck:client",
"typecheck:server": "pnpm --filter server type-check",
"typecheck:client": "pnpm --filter client type-check",
"typecheck:client:baseline": "node ./scripts/client-typecheck-baseline.mjs check",
"typecheck:client:baseline:update": "node ./scripts/client-typecheck-baseline.mjs update",
"lint": "pnpm run lint:check",
"lint:check": "pnpm --filter server lint:check && pnpm --filter client lint:check",
"lint:fix": "pnpm --filter server lint:fix && pnpm --filter client lint:fix",
"format": "pnpm --filter server format && pnpm --filter client format",
@@ -33,12 +26,9 @@
"coverage": "pnpm run coverage:server && pnpm run coverage:client",
"coverage:server": "pnpm --filter server test:cov",
"coverage:client": "pnpm --filter client test:cov --run",
"db:up": "docker compose up -d --wait",
"prod:up": "docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --wait",
"prod:down": "docker compose --env-file .env.prod -f docker-compose.prod.yml down",
"db:backup:prod": "bash ./scripts/db/backup-prod.sh .env.prod",
"db:restore:prod": "bash ./scripts/db/restore-prod.sh .env.prod",
"db:restore:test:prod": "bash ./scripts/db/restore-test-prod.sh .env.prod",
"db:up": "docker compose -f docker-compose.dev.yml up -d --wait",
"prod:up": "docker compose up -d --wait",
"prod:down": "docker compose down",
"db:migrate": "pnpm --filter server db:migrate",
"db:reset": "bash ./scripts/db/reset.sh",
"db:seed": "bash ./scripts/db/seed.sh",
-1
View File
@@ -108,7 +108,6 @@ export interface AuditLogEntry {
resourceId: number | null;
description: string;
ip: string | null;
countryCode: string | null;
meta: Record<string, unknown> | null;
createdAt: string;
}
-120
View File
@@ -1,120 +0,0 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
const mode = process.argv[2] ?? 'check'
const rootDir = process.cwd()
const baselinePath = resolve(rootDir, 'client/typecheck-baseline.txt')
function runClientTypecheck() {
const result = spawnSync('pnpm', ['--filter', 'client', 'exec', 'vue-tsc', '--build', '--pretty', 'false'], {
cwd: rootDir,
encoding: 'utf8',
})
if (result.error) {
throw result.error
}
const combinedOutput = `${result.stdout ?? ''}\n${result.stderr ?? ''}`
const diagnostics = parseDiagnostics(combinedOutput)
return { result, combinedOutput, diagnostics }
}
function parseDiagnostics(output) {
const regex = /^(.*\.(?:ts|tsx|vue)\(\d+,\d+\): error TS\d+: .*)$/gm
const rows = []
for (const match of output.matchAll(regex)) {
const raw = String(match[1] ?? '').trim()
rows.push(normalizeDiagnostic(raw))
}
return [...new Set(rows)].sort((a, b) => a.localeCompare(b))
}
function normalizeDiagnostic(row) {
const rootPrefix = `${rootDir.replaceAll('\\', '/')}/`
let normalized = row.replaceAll('\\', '/')
normalized = normalized.split(`${rootPrefix}client/`).join('')
normalized = normalized.split(rootPrefix).join('')
if (normalized.startsWith('client/')) normalized = normalized.slice('client/'.length)
return normalized
}
function readBaseline() {
if (!existsSync(baselinePath)) return []
return readFileSync(baselinePath, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
}
function writeBaseline(diagnostics) {
mkdirSync(dirname(baselinePath), { recursive: true })
const header = [
'# Client typecheck baseline',
'# Auto-generated by: pnpm run typecheck:client:baseline:update',
'# One diagnostic per line. Do not edit manually unless necessary.',
'',
]
writeFileSync(baselinePath, `${header.join('\n')}${diagnostics.join('\n')}\n`, 'utf8')
}
if (mode === 'update') {
const { result, combinedOutput, diagnostics } = runClientTypecheck()
if (result.status !== 0 && diagnostics.length === 0) {
console.error('Client typecheck failed without parsable diagnostics output.')
console.error(combinedOutput)
process.exit(1)
}
writeBaseline(diagnostics)
console.log(`Updated ${baselinePath} with ${diagnostics.length} diagnostics.`)
process.exit(0)
}
if (mode !== 'check') {
console.error(`Unknown mode "${mode}". Use "check" or "update".`)
process.exit(2)
}
const { result, combinedOutput, diagnostics } = runClientTypecheck()
if (result.status === 0) {
console.log('Client typecheck is clean.')
process.exit(0)
}
if (diagnostics.length === 0) {
console.error('Client typecheck failed without parsable diagnostics output.')
console.error(combinedOutput)
process.exit(1)
}
const baseline = readBaseline()
if (baseline.length === 0) {
console.error(`No baseline found at ${baselinePath}.`)
console.error('Run: pnpm run typecheck:client:baseline:update')
process.exit(1)
}
const baselineSet = new Set(baseline)
const newDiagnostics = diagnostics.filter((line) => !baselineSet.has(line))
if (newDiagnostics.length > 0) {
console.error(`Found ${newDiagnostics.length} new client type errors not present in baseline:`)
for (const line of newDiagnostics) {
console.error(`- ${line}`)
}
console.error('If these are intentional and accepted, refresh baseline:')
console.error('pnpm run typecheck:client:baseline:update')
process.exit(1)
}
const resolvedCount = baseline.filter((line) => !diagnostics.includes(line)).length
console.log(`No new client type errors. Baseline size: ${baseline.length}. Current: ${diagnostics.length}. Resolved: ${resolvedCount}.`)
if (resolvedCount > 0) {
console.log('You can refresh baseline to capture improvements:')
console.log('pnpm run typecheck:client:baseline:update')
}
process.exit(0)
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
ENV_FILE="${1:-.env.prod}"
OUT_DIR="${2:-$ROOT_DIR/local/backups}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing env file: $ENV_FILE" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${POSTGRES_USER:?POSTGRES_USER is required}"
: "${POSTGRES_DB:?POSTGRES_DB is required}"
mkdir -p "$OUT_DIR"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
backup_file="$OUT_DIR/projectx-${POSTGRES_DB}-${timestamp}.dump"
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --wait postgres
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc >"$backup_file"
echo "Backup created: $backup_file"
+8 -8
View File
@@ -8,16 +8,16 @@ POSTGRES_USER="${POSTGRES_USER:-projectx}"
POSTGRES_DB="${POSTGRES_DB:-projectx}"
echo "Ensuring PostgreSQL is running..."
docker compose up -d --wait postgres
docker compose -f docker-compose.dev.yml up -d --wait postgres
echo "Resetting database schema..."
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS vector;"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE SCHEMA IF NOT EXISTS drizzle;"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint);"
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM drizzle.__drizzle_migrations;"
docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";"
docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE EXTENSION IF NOT EXISTS vector;"
docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE SCHEMA IF NOT EXISTS drizzle;"
docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint);"
docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM drizzle.__drizzle_migrations;"
echo "Re-applying migrations..."
pnpm run db:migrate
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
ENV_FILE="${1:-.env.prod}"
BACKUP_FILE="${2:-}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing env file: $ENV_FILE" >&2
exit 1
fi
if [[ -z "$BACKUP_FILE" || ! -f "$BACKUP_FILE" ]]; then
echo "Usage: $0 <env-file> <backup-file.dump>" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${POSTGRES_USER:?POSTGRES_USER is required}"
: "${POSTGRES_DB:?POSTGRES_DB is required}"
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --wait postgres
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$POSTGRES_DB' AND pid <> pg_backend_pid();" >/dev/null
cat "$BACKUP_FILE" | docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges
echo "Restore complete: $BACKUP_FILE -> $POSTGRES_DB"
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
ENV_FILE="${1:-.env.prod}"
BACKUP_FILE="${2:-}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing env file: $ENV_FILE" >&2
exit 1
fi
if [[ -z "$BACKUP_FILE" || ! -f "$BACKUP_FILE" ]]; then
echo "Usage: $0 <env-file> <backup-file.dump>" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${POSTGRES_USER:?POSTGRES_USER is required}"
: "${POSTGRES_DB:?POSTGRES_DB is required}"
test_db="${POSTGRES_DB}_restore_test_$(date -u +%Y%m%d%H%M%S)"
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --wait postgres
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d postgres -c "CREATE DATABASE \"$test_db\";" >/dev/null
cleanup() {
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d postgres -c "DROP DATABASE IF EXISTS \"$test_db\";" >/dev/null || true
}
trap cleanup EXIT
cat "$BACKUP_FILE" | docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
pg_restore -U "$POSTGRES_USER" -d "$test_db" --no-owner --no-privileges >/dev/null
table_count="$(
docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \
psql -U "$POSTGRES_USER" -d "$test_db" -Atc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';"
)"
echo "Restore test succeeded: $BACKUP_FILE -> $test_db (${table_count} public tables)"
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
echo "Checking toolchain..."
node -v
pnpm -v
echo "Checking environment files..."
[[ -f server/.env.example ]] || { echo "Missing server/.env.example"; exit 1; }
[[ -f server/.env ]] || { echo "Missing server/.env (run: pnpm setup)"; exit 1; }
echo "Checking PostgreSQL container..."
docker compose ps postgres
POSTGRES_USER="${POSTGRES_USER:-projectx}"
POSTGRES_DB="${POSTGRES_DB:-projectx}"
docker compose exec -T postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
echo "Checking local scripts..."
[[ -d scripts/db ]] || { echo "Missing scripts/db helpers"; exit 1; }
[[ -f scripts/help.mjs ]] || { echo "Missing scripts/help.mjs"; exit 1; }
echo "Doctor check passed."
-24
View File
@@ -1,24 +0,0 @@
const lines = [
'ProjectX local workflow',
'',
'Core commands',
' pnpm setup # one-time bootstrap on a machine',
' pnpm dev # run server + client for daily work',
' pnpm verify # default local quality gate before push',
'',
'Optional',
' pnpm quick # faster local confidence loop',
' pnpm verify:strict # aspirational strict gate (format + full typecheck)',
' pnpm guide # print this command reference',
' pnpm typecheck:client:baseline:update # refresh accepted client TS baseline',
' pnpm doctor # environment health checks',
' pnpm coverage # coverage reports for server + client',
'',
'Database helpers',
' pnpm db:up',
' pnpm db:migrate',
' pnpm db:reset',
' pnpm db:seed',
];
process.stdout.write(`${lines.join('\n')}\n`);
-140
View File
@@ -1,140 +0,0 @@
#!/usr/bin/env bash
# Seeds synthetic reading sessions and daily stats for the first user so that
# all user-statistics charts (heatmap, peak hours, completion timeline, etc.)
# show realistic data out of the box.
#
# Safe to run multiple times - sessions are deduplicated by session_id and
# daily stats are upserted. Running again adds more data on top.
#
# Usage: ./scripts/seed/reading-sessions.sh [USER_ID]
# USER_ID defaults to 1.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
POSTGRES_USER="${POSTGRES_USER:-projectx}"
POSTGRES_DB="${POSTGRES_DB:-projectx}"
TARGET_USER_ID="${1:-1}"
echo "Seeding reading sessions for user_id=${TARGET_USER_ID}..."
docker compose exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" << EOSQL
DO \$\$
DECLARE
v_user_id int := ${TARGET_USER_ID};
v_day date;
v_file_id int;
v_library_id int;
v_sessions int;
v_i int;
v_hour int;
v_duration int;
v_started timestamptz;
v_ended timestamptz;
v_end_pct real;
v_delta real;
v_session_id text;
v_dow int;
v_file_ids int[];
v_total_sessions int := 0;
v_total_completions int := 0;
BEGIN
-- Verify user exists
IF NOT EXISTS (SELECT 1 FROM users WHERE id = v_user_id) THEN
RAISE EXCEPTION 'User with id % not found', v_user_id;
END IF;
-- Verify there are book files to work with
IF NOT EXISTS (SELECT 1 FROM book_files LIMIT 1) THEN
RAISE EXCEPTION 'No book files found - scan a library first';
END IF;
-- Pick up to 40 real file ids to spread sessions across
SELECT array_agg(id ORDER BY random()) INTO v_file_ids
FROM (SELECT id FROM book_files ORDER BY random() LIMIT 40) t;
-- Spread books.added_at across the past 3 years so Format Share Over Time
-- chart has data across multiple months instead of a single spike today.
UPDATE books SET added_at = now() - (random() * interval '3 years')
WHERE added_at > now() - interval '7 days';
-- Insert sessions across the past 365 days
FOR v_day IN
SELECT generate_series(
current_date - interval '364 days',
current_date,
interval '1 day'
)::date
LOOP
v_dow := extract(dow from v_day); -- 0=Sun 6=Sat
-- Weekends: 1-3 sessions. Weekdays: 0-2 (30% chance of 0).
IF v_dow IN (0, 6) THEN
v_sessions := 1 + floor(random() * 3)::int;
ELSIF random() < 0.30 THEN
v_sessions := 0;
ELSE
v_sessions := 1 + floor(random() * 2)::int;
END IF;
FOR v_i IN 1..v_sessions LOOP
v_file_id := v_file_ids[1 + floor(random() * array_length(v_file_ids, 1))::int];
SELECT b.library_id INTO v_library_id
FROM book_files bf JOIN books b ON b.id = bf.book_id
WHERE bf.id = v_file_id;
-- Realistic hour distribution: mostly evenings, some mornings/afternoons
IF random() < 0.60 THEN
v_hour := 18 + floor(random() * 6)::int; -- 18-23 evening
ELSIF random() < 0.50 THEN
v_hour := 7 + floor(random() * 3)::int; -- 7-9 morning
ELSE
v_hour := 12 + floor(random() * 4)::int; -- 12-15 afternoon
END IF;
v_started := (v_day + make_interval(hours => v_hour, mins => floor(random() * 60)::int))::timestamptz;
v_duration := 600 + floor(random() * 3000)::int; -- 10-60 min
v_ended := v_started + make_interval(secs => v_duration);
v_delta := round((1 + random() * 8)::numeric, 4); -- 1-9% progress
-- ~15% of sessions are completions (every 7th deterministically by loop)
IF (v_total_sessions % 7) = 0 THEN
v_end_pct := 100;
v_total_completions := v_total_completions + 1;
ELSE
v_end_pct := round((10 + random() * 80)::numeric, 2);
END IF;
v_session_id := md5(v_user_id::text || v_file_id::text || v_started::text || v_i::text);
INSERT INTO reading_sessions
(user_id, book_file_id, session_id, started_at, ended_at, duration_seconds, progress_delta, end_progress)
VALUES
(v_user_id, v_file_id, v_session_id, v_started, v_ended, v_duration, v_delta, v_end_pct)
ON CONFLICT (session_id) DO NOTHING;
-- Real-time upsert of daily stats (mirrors what the server does on session save)
INSERT INTO user_reading_daily_stats
(user_id, library_id, day, reading_seconds, progress_delta, sessions_count, updated_at)
VALUES
(v_user_id, v_library_id, v_day, v_duration, v_delta, 1, now())
ON CONFLICT (user_id, library_id, day) DO UPDATE SET
reading_seconds = user_reading_daily_stats.reading_seconds + excluded.reading_seconds,
progress_delta = user_reading_daily_stats.progress_delta + excluded.progress_delta,
sessions_count = user_reading_daily_stats.sessions_count + 1,
updated_at = now();
v_total_sessions := v_total_sessions + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Done: % sessions inserted (% completions)', v_total_sessions, v_total_completions;
END;
\$\$;
EOSQL
echo "Reading session seed complete."
+1 -3
View File
@@ -13,12 +13,10 @@ echo "Installing dependencies..."
pnpm install
echo "Starting PostgreSQL..."
docker compose up -d --wait
docker compose -f docker-compose.dev.yml up -d --wait
echo "Applying migrations..."
pnpm run db:migrate
echo "Seeding baseline data..."
pnpm run db:seed
pnpm run doctor
+4 -5
View File
@@ -13,15 +13,14 @@ JWT_REFRESH_EXPIRES_IN=7d
SETUP_BOOTSTRAP_TOKEN=
# Storage
# App data directory for cover images and generated files.
# App data directory for covers, author images, user avatars, and cache.
# This path resolves relative to server/ (where the process runs).
# ../local/data points to <project-root>/local/data.
# In production/Docker this is typically an absolute path like /data.
BOOKS_PATH=../local/data
# Staging (optional — defaults to {BOOKS_PATH}/staging)
# Directory where staging files are uploaded and watched for new drops.
# STAGING_PATH=../local/data/staging
# Staging directory where files are dropped for auto-import.
# Defaults to {BOOKS_PATH}/staging if unset.
# STAGING_PATH=../local/staging
# OIDC (optional — configure via admin settings UI; these are just for reference)
# OIDC config is stored in the database (app_settings table, key: oidc_config)
-6
View File
@@ -1,6 +0,0 @@
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "start:dev"]
+9
View File
@@ -1,3 +1,12 @@
#!/bin/sh
set -e
if [ -z "$DATABASE_URL" ]; then
export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB}"
fi
export BOOKS_PATH="${BOOKS_PATH:-/data}"
export STAGING_PATH="${STAGING_PATH:-/staging}"
node dist/scripts/migrate.js
exec node dist/main.js
+1 -3
View File
@@ -60,13 +60,12 @@
"fast-xml-parser": "^5.5.9",
"fastest-levenshtein": "^1.0.16",
"fastify": "^5.8.4",
"geoip-lite": "^2.0.1",
"jose": "^6.2.2",
"ms": "^2.1.3",
"music-metadata": "^11.12.3",
"nestjs-pino": "^4.6.1",
"node-unrar-js": "^2.0.2",
"nodemailer": "^8.0.3",
"nodemailer": "^8.0.4",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdf-lib": "^1.17.1",
@@ -89,7 +88,6 @@
"@swc/core": "^1.15.21",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.6",
"@types/geoip-lite": "^1.4.4",
"@types/node": "^25.5.0",
"@types/nodemailer": "^6.4.23",
"@types/passport-jwt": "^4.0.1",
+2 -2
View File
@@ -4,7 +4,7 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { LoggerModule } from 'nestjs-pino';
import { appConfig, authConfig, dbConfig, emailConfig, externalApiConfig, storageConfig } from './config/config';
import { appConfig, authConfig, dbConfig, emailConfig, storageConfig } from './config/config';
import { validateEnv } from './config/env.validation';
import { loggerConfig } from './common/logger.config';
import { CommonModule } from './common/common.module';
@@ -58,7 +58,7 @@ import { AuditModule } from './modules/audit/audit.module';
ConfigModule.forRoot({
isGlobal: true,
validate: validateEnv,
load: [appConfig, dbConfig, authConfig, storageConfig, externalApiConfig, emailConfig],
load: [appConfig, dbConfig, authConfig, storageConfig, emailConfig],
}),
ScheduleModule.forRoot(),
DbModule,
+1 -7
View File
@@ -1,9 +1,7 @@
import { registerAs } from '@nestjs/config';
export const appConfig = registerAs('app', () => ({
port: parseInt(process.env.PORT ?? '3000', 10),
nodeEnv: process.env.NODE_ENV ?? 'development',
logLevel: process.env.LOG_LEVEL,
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
}));
@@ -19,14 +17,10 @@ export const authConfig = registerAs('auth', () => ({
}));
export const storageConfig = registerAs('storage', () => ({
booksPath: process.env.BOOKS_PATH ?? '/data/books',
booksPath: process.env.BOOKS_PATH ?? '/data',
stagingPath: process.env.STAGING_PATH,
}));
export const externalApiConfig = registerAs('externalApi', () => ({
googleBooksApiKey: process.env.GOOGLE_BOOKS_API_KEY ?? '',
}));
export const emailConfig = registerAs('email', () => ({
encryptionKey: process.env.EMAIL_ENCRYPTION_KEY ?? '',
}));
+2 -4
View File
@@ -2,8 +2,7 @@ import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL'),
DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL').optional(),
JWT_SECRET: z
.string()
.min(16, 'JWT_SECRET must be at least 16 characters')
@@ -14,10 +13,9 @@ const envSchema = z.object({
JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
SETUP_BOOTSTRAP_TOKEN: z.string().optional(),
BOOKS_PATH: z.string().default('/data/books'),
BOOKS_PATH: z.string().default('/data'),
STAGING_PATH: z.string().optional(),
CLIENT_URL: z.string().url().optional(),
GOOGLE_BOOKS_API_KEY: z.string().default(''),
APP_URL: z.string().url().default('http://localhost:5173'),
EMAIL_ENCRYPTION_KEY: z.string().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(),
@@ -10,7 +10,6 @@ CREATE TABLE "audit_log" (
"resource_id" integer,
"description" text NOT NULL,
"ip" varchar(45),
"country_code" char(2),
"meta" jsonb,
"created_at" timestamp DEFAULT now() NOT NULL
);
@@ -1,5 +1,5 @@
{
"id": "044c15cb-ab99-4b1f-b88f-3c025644dec7",
"id": "2e38901e-f8a5-4f93-8564-e0f8f44e1883",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -56,12 +56,6 @@
"primaryKey": false,
"notNull": false
},
"country_code": {
"name": "country_code",
"type": "char(2)",
"primaryKey": false,
"notNull": false
},
"meta": {
"name": "meta",
"type": "jsonb",
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1774336038252,
"when": 1774487725854,
"tag": "0000_baseline",
"breakpoints": true
}
+1 -2
View File
@@ -1,4 +1,4 @@
import { bigserial, char, index, integer, jsonb, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { bigserial, index, integer, jsonb, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { users } from './auth';
@@ -13,7 +13,6 @@ export const auditLog = pgTable(
resourceId: integer('resource_id'),
description: text('description').notNull(),
ip: varchar('ip', { length: 45 }),
countryCode: char('country_code', { length: 2 }),
meta: jsonb('meta'),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
@@ -1,5 +1,4 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import * as geoip from 'geoip-lite';
import { AUDIT_EVENT, AuditEventPayload, AuditEventsService } from './audit-events.service';
import { AuditRepository, AuditLogQuery } from './audit.repository';
@@ -30,8 +29,6 @@ export class AuditService implements OnModuleInit, OnModuleDestroy {
}
private handleAuditEvent(payload: AuditEventPayload): void {
const countryCode = payload.ip ? (geoip.lookup(payload.ip)?.country ?? null) : null;
this.auditRepository
.insert({
userId: payload.userId,
@@ -41,7 +38,6 @@ export class AuditService implements OnModuleInit, OnModuleDestroy {
resourceId: payload.resourceId ?? null,
description: payload.description,
ip: payload.ip ?? null,
countryCode,
meta: payload.meta ?? null,
})
.catch((err: unknown) => {
+6
View File
@@ -33,6 +33,12 @@ async function runMigrations() {
});
try {
await pool.query(`
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
CREATE EXTENSION IF NOT EXISTS "vector";
`);
const migrationsFolder = resolveMigrationsFolder();
await migrate(drizzle(pool), { migrationsFolder });