Skip to content

refactor: rewrite backend core — SQLite persistence, on-demand statistics, incremental updates#59

Merged
paviro merged 105 commits into
mainfrom
api-core-revamp
Mar 15, 2026
Merged

refactor: rewrite backend core — SQLite persistence, on-demand statistics, incremental updates#59
paviro merged 105 commits into
mainfrom
api-core-revamp

Conversation

@paviro

@paviro paviro commented Mar 15, 2026

Copy link
Copy Markdown
Owner

Major internal rewrite of KoShelf's API and data layer:

  • Much lower RAM usage — library data now lives in SQLite and statistics are computed per-request instead of pre-computed and held in memory. Cover images are written to disk one at a time during ingest rather than buffered.
  • Incremental updates — file changes are detected by fingerprint; only affected items are re-processed instead of rebuilding the entire library.
  • New reading API — all reading data consolidated under /api/reading/* with query parameters for scope, date range, and timezone. Old /api/activity/* and /api/completions/* endpoints removed.
  • Config file support — settings can now be provided via koshelf.toml (see koshelf.example.toml). CLI flags still override.
  • Updated static export — static exports produce JSON data files matching the API shape, consumed by a StaticApiClient in the frontend.
  • API documentation — added full API reference (docs/API.md).
  • jemalloc allocator on non-Windows platforms.
  • Calendar versioning — switched from semver to YYYY.M.patch (this release: 2026.3.0).

Security

  • Fixed path traversal in CBR extraction and EPUB path resolution
  • Sandboxed Lua metadata parser (disabled stdlib and bytecode loading)
  • Removed permissive CORS policy (embedded frontend is same-origin)
  • Added X-Content-Type-Options, X-Frame-Options, and Referrer-Policy headers

Dependency upgrades

  • Tailwind CSS v3 → v4, react-router v6 → v7, Vite v6 → v8
  • rusqlite → sqlx (async SQLite)
  • Bumped @Event-calendar, typescript-eslint, vitest, and other minor frontend deps

Code structure

  • src/source/ — external data sources (KOReader, parsers, scanner, fingerprinting)
  • src/store/ — persistence (SQLite + in-memory state stores)
  • src/shelf/ — business logic & types (library service, reading statistics)
  • src/server/ — HTTP server (api/ handlers, response DTOs, embedded frontend)
  • src/pipeline/ — orchestration (ingest, rebuild, export, watcher, media, recap)
  • src/app/ — bootstrap (CLI, config, entry point)

Breaking: /api/activity/* and /api/completions/* removed in favor of /api/reading/*. Response envelope is now { data, meta: { version, generated_at } }. has_activity/has_completions capabilities replaced by has_reading_data.

paviro added 30 commits March 12, 2026 00:38
Define the initial library.sqlite projection tables and indexes plus schema-version checks so upcoming sync and query work can build on a stable, test-verified foundation.
Add shared file fingerprint and reconcile classification types so startup and watcher sync can consistently choose targeted reparse scopes.
Replace rusqlite with sqlx for all SQLite access, gaining unified async
queries and compile-time embedded migrations via `sqlx::migrate!`.
Only the library DB currently uses resolved_data_dir; stats DB copy and
cover cache do not.
Add LibraryBuildPipeline that populates library.sqlite from scanned
source files at startup, supporting full and incremental build modes.
Incremental mode uses file fingerprint reconciliation to detect
added/changed/removed items. Canonical-ID collision detection selects
deterministic winners by earliest file path and records diagnostics.
Replace the in-memory ContractSnapshot data source for /api/items and
/api/items/{id} with direct queries against library.sqlite via
LibraryRepository.

Key changes:
- Wire LibraryRepository into ServerState and WebServer
- Rewrite LibraryService to query the DB (async) instead of snapshot
- Add projections module (LibraryItemRow → contract type mapping)
- Add domain-level ItemSort/SortOrder with scope/sort/order query params
- Return 404 not_found for missing /api/items/{id}
- Add InvalidQuery error code with custom message support
- Update frontend API client to use scope= instead of content_type=
- Detail endpoint returns highlights/bookmarks from DB, statistics
  empty during transition (stats DB integration comes with includes)
Implement strict include token parsing for /api/items/{id} with
highlights, bookmarks, statistics, completions, and all tokens.
Omitting include returns base payload only; unknown tokens return
400 invalid_query. Align contract duration fields to _sec naming
convention. Statistics and completions data remain nullable pending
stats DB query service integration.

Frontend updated to pass include=all on detail requests and use
the new optional response shape and renamed duration fields.
After each debounced snapshot refresh, the file watcher now also runs an
incremental library build against library.sqlite using the existing
fingerprint-based reconciliation pipeline. This keeps the SQLite-backed
item data in sync with file system changes during serve and watch-static
modes.
…ading endpoints

Define the response DTOs, domain query types, and shared parameter
validation infrastructure that the five /api/reading/* endpoints will
build on. All purely additive — no routes or behavior changes yet.

New files:
- contracts/reading.rs: response data types for summary, metrics,
  available-periods, calendar, and completions endpoints
- domain/reading/queries.rs: validated domain query types (DateRange,
  ReadingMetric, MetricsGroupBy, PeriodSource, PeriodGroupBy,
  CompletionsSelector, CompletionsIncludeSet, composed query structs)

Updated files:
- contracts/common.rs: generic ApiResponse<T> envelope + ResponseMeta
- contracts/mod.rs: register reading module
- domain/reading/mod.rs: register queries module
- server/api/shared.rs: Axum query param structs and parsing functions
  for all reading endpoints with full validation coverage
Add reading data infrastructure that preserves processed StatisticsData
alongside the snapshot, enabling on-demand query computation for new
reading endpoints. The ReadingDataStore follows the same thread-safe
atomic-replace pattern as SnapshotStore.

The summary endpoint computes overview metrics, streaks, and heatmap
config from raw stats data at request time, applying scope, date-range,
and timezone filters dynamically.
Extract reusable helpers (resolve_time_config, filter_and_resolve_range,
filter_stats_by_scope, count_completions_in_range) into shared.rs and
move summary computation + compute_streaks into summary.rs. service.rs
becomes a thin delegation layer. No behavior change.
Rename legacy metrics.rs to activity.rs (serves /api/activity/* routes)
and add new metrics.rs for the /api/reading/metrics endpoint.

Supports 6 metric types (reading_time_sec, pages_read, sessions,
completions, average_session_duration_sec, longest_session_duration_sec)
with day/week/month bucketing. Produces continuous zero-filled time
series over the resolved date range.

Bucket key helpers (bucket_key_day, bucket_key_week, bucket_key_month)
are placed in shared.rs for reuse by upcoming available-periods endpoint.
…d computation

Add period discovery endpoint for week/month/year pickers used by
Statistics, Calendar, and Recap routes. Supports two selector sources:
- source=reading_data: periods with reading activity (week/month/year)
- source=completions: periods with completed items (month/year only)

Each period entry includes start/end dates and lightweight stats
(reading_time_sec, pages_read, completions) for badge rendering.
…tion

Add the calendar endpoint that returns month-scoped reading events,
item references, and per-scope stats computed on demand from
StatisticsData. Consecutive reading days are merged into event spans.

- calendar.rs: reading_calendar() with helpers for month range parsing,
  stats-by-scope computation, event building, and day merging
- service.rs: add ReadingService::calendar() dispatch
- reading.rs: add reading_calendar async handler
- shared.rs: activate calendar params and parser (remove dead_code)
- web.rs: register /api/reading/calendar route
- 9 unit tests covering date ranges, event merging, scope filtering,
  and month boundary exclusion
…tcher invalidation

Replace legacy snapshot_updated SSE event with data_changed carrying
revision_epoch, per-domain revision counters, affected domains list,
and generated_at timestamp. The watcher now classifies file events by
domain (Library, Metadata, Stats, Assets) and accumulates affected
domains across the debounce window for targeted invalidation instead
of bumping all domains on every change.
Replace snapshot-based static export with new-contract data files generated
from LibraryService and ReadingService. Exports site.json, items/index.json,
items/{id}.json, reading/summary.json, reading/periods.json,
reading/metrics/{YYYY-MM}.json, reading/calendar/{YYYY-MM}.json, and
reading/completions/{YYYY}.json. Runs alongside legacy snapshot export during
the transition period.
…ates

The watcher in WatchStatic mode now re-exports all new-contract data
files (site.json, items/, reading/) after each incremental library DB
update. Reading data from the snapshot refresh is preserved across the
rebuild loop for use by the exporter.
Replace has_activity/has_completions with single has_reading_data
capability key across backend contracts and frontend. Update SSE
handling from snapshot_updated to data_changed event with domain-aware
revision tracking. Merge navigation capability checks into unified
has_reading_data guard.
Rewrite shared/contracts.ts with all new reading API response types
(ApiResponse<T>, ReadingSummaryData, ReadingMetricsData,
ReadingAvailablePeriodsData, ReadingCalendarData, ReadingCompletionsData)
and static export aggregate types for client-side scope extraction.

Rewrite shared/api.ts to replace activity.* and completions.* namespaces
with a unified reading.* namespace backed by /api/reading/* endpoints.
Add dual-mode support (serve vs static) with static JSON caching,
scope-aware data extraction, and client-side metrics assembly.
Rewrite statistics-data.ts to compose data from reading/summary,
reading/metrics, and reading/available-periods endpoints. Align all
field names (read_time→reading_time_sec, completed_count→completions,
max_scale_seconds→max_scale_sec, etc.) across model, hooks, route,
and section components.
Rewrite calendar-data.ts to use reading/available-periods and
reading/calendar endpoints. Simplify from 3 parallel per-scope calls
to a single call with stats_by_scope. Align field names across all
calendar components (item_id→item_ref, time_read→reading_time_sec,
total_read_time→reading_time_sec, days_read_pct→active_days_percentage).
Rewrite recap-data.ts to use reading/available-periods (completions,
year) and reading/completions endpoints. Replace old domain types with
contract types (CompletionsSummary, CompletionItem, CompletionGroup,
CompletionsShareAssets). Update RecapSummarySection to compute
hours/minutes/days from raw seconds. Align all field names across
components (month_key→key, read_time→reading_time_sec,
reading_time→reading_time_sec, longest_streak→longest_streak_days).
…ticApiClient implementations

Split the monolithic api.ts into a proper interface-based architecture:
- ApiClient interface defines the contract for all data access
- HttpApiClient handles serve-mode requests to /api/* endpoints
- StaticApiClient handles static-mode file loading with client-side
  scope filtering, metrics assembly, and completions filtering
- Lazy client creation ensures mode detection runs after entry point
  initialization
- Fixed static cache not being cleared on site version change in
  static-mode polling
… detail endpoint

Move ReadingData struct from runtime to models so both library and
reading domain services can depend on it without violating dependency
rules. The library detail service now resolves statistics and
completions on demand via partial_md5_checksum linkage into the
in-memory reading data when include=statistics or include=completions
is requested. Static export also passes reading data through for
per-item detail files.
paviro added 21 commits March 14, 2026 21:48
- Add TOML config file loading with `--config` / `-c` flag
- Default path `./koshelf.toml` auto-loaded when present
- CLI flags override config file values (precedence: CLI > config > defaults)
- Add `koshelf.example.toml` with all available settings
- Rename CLI `--data-dir` to `--data-path` (kept `--data-dir` as alias)
- Restructure app/config into submodules: cli, file, site
…DataPath to DataPath

Move config merging orchestration out of cli.rs into mod.rs where it
belongs — cli.rs now only defines the CLI struct and validation, while
mod.rs mediates between Cli and FileConfig. Also rename CliDataPath to
DataPath so the log source label is accurate regardless of whether the
value came from CLI or the config file.
When using a persistent DB and switching output directories (e.g. serve
mode → --output, or deleting the assets folder), covers were not
regenerated because fingerprint reconciliation marked items as unchanged.
Now the existing cover_needs_generation check is applied during
reconciliation so items with missing covers are automatically re-ingested.
Prevents 404 errors in static export mode by loading the available
months list before prefetching, so only months with data are fetched.
…call

Add ActiveDays as a new ReadingMetric that counts distinct reading days
per bucket. Use it to replace the yearly section's two API calls
(summary + 365-point day metrics) with a single 12-point monthly metrics
call, moving aggregation server-side and removing aggregateMonthlyStats.
StaticApiClient.getReadingCalendar was ignoring the scope parameter,
so calendar events were never filtered by content type in static mode.

Add two tests that fail when a new API route is added without a
corresponding static export: one counts .route() calls in source and
compares to route_paths(), the other checks every route is listed in
the export coverage registry.
Harden locate_metadata_path by replacing unwrap() calls on file_stem/parent
with the ? operator for safe early return. Unify the rating field to
Option<i32> across all API responses to match what sqlx returns from SQLite,
removing unnecessary u32 casts.
Move from semver to YYYY.M.patch format and remove the "v" prefix from
the sidebar version display.
@event-calendar/build 5.4.1→5.4.2, @event-calendar/core 5.4.1→5.4.2,
@types/node 25.3.5→25.5.0, typescript-eslint 8.56.1→8.57.0,
vitest 4.0.18→4.1.0
- Replace react-router-dom with react-router (v7 consolidated package)
- Remove v7 future flags from HashRouter (now default behavior)
- Upgrade @vitejs/plugin-react 4→6 (required by vite 8)
- Update all imports across 15 source files
StaticApiClient.getReadingSummary now respects from/to params by computing
ranged summaries client-side from daily metric files. Export session duration
metrics (longest/average) to support this. Write scope-filtered items files
(books.json, comics.json) so the frontend fetches only the needed scope.
…sion

Write a .version marker (version + content hash) to the output directory
after syncing the embedded frontend. On subsequent runs, skip the copy if
the marker matches — avoids redundant file writes in watch mode and still
ensures a new binary with updated frontend assets triggers a re-sync.
The version marker check alone isn't enough — if a user deletes
frontend files the marker still matches and nothing gets recopied.
Now also verify all embedded files exist before skipping the sync.
Move all frontend embedding, versioning, and sync logic out of media.rs
into its own frontend.rs module. media.rs now only handles directories
and cover generation.
CBR cover extraction used raw archive entry filenames in temp path
construction, allowing ../  sequences to write outside the temp dir.
EPUB path resolution joined XML-sourced paths without stripping ..
components before zip lookups.
- Replace Lua::new() with Lua::new_with(StdLib::NONE) to prevent
  arbitrary code execution via malicious metadata files (os, io, ffi)
- Reject Lua bytecode with ChunkMode::Text to prevent VM exploitation
- Remove CorsLayer::permissive() — embedded frontend is same-origin
- Add X-Content-Type-Options, X-Frame-Options, Referrer-Policy headers
CSS-first configuration: replace tailwind.config.cjs with @theme block
in app.css, @custom-variant dark, @plugin directives, and @source for
index.html detection. Convert @layer utilities/components to @Utility
blocks. Delete JS config file.

PostCSS: swap tailwindcss plugin for @tailwindcss/postcss. Remove
@tailwindcss/container-queries (built into v4 core).

calendar.css: convert theme('calendar.*') to CSS custom properties,
theme('colors.*') to var(--color-*), add @reference "tailwindcss" with
minimal @theme subset for @apply support, use text-[0.65rem]/[1rem]
arbitrary value for event font sizing.

Template class renames (automated): bg-gradient-to-* → bg-linear-to-*,
shadow-sm → shadow-xs, shadow → shadow-sm, backdrop-blur-sm →
backdrop-blur-xs, flex-shrink-0 → shrink-0, rounded → rounded-sm,
focus:outline-none → focus:outline-hidden, !important → postfix !

Manual fixes for v4 behavioral changes:
- aspect-book: @theme --aspect-ratio-* doesn't generate utilities in v4,
  replaced with explicit @Utility block
- calendar shadow: rename shadow-sm → shadow-xs in calendar.css @apply
  (not processed by upgrade tool)
- calendar border: remove .ec-main 3-sided border that became visible
  due to v4 border-color compatibility layer
- scope selector: replace space-x-* with gap + explicit padding to fix
  v4 margin direction change with hidden siblings
- leading-none leak: v4's --tw-leading variable persists across
  responsive text variants; use text-{size}/none on base only so
  responsive variants (md:text-3xl etc.) use their default line-heights
- tsconfig.node.json: add target ES2022 for rolldown compatibility
@paviro paviro changed the title refactor: rewrite API core — SQLite persistence, on-demand statistics, incremental updates refactor: rewrite backend core — SQLite persistence, on-demand statistics, incremental updates Mar 15, 2026
Comment thread src/shelf/statistics/summary.rs Dismissed
paviro added 2 commits March 15, 2026 02:24
The `fs` module is only used in the non-Windows CBR parsing path,
causing an unused import warning on Windows GNU builds.
@paviro
paviro merged commit 8cb62c4 into main Mar 15, 2026
3 checks passed
@paviro
paviro deleted the api-core-revamp branch March 15, 2026 01:26
@paviro
paviro restored the api-core-revamp branch March 15, 2026 01:28
@paviro
paviro deleted the api-core-revamp branch March 15, 2026 01:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants