Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
1ad4871
init
CD-Z Apr 13, 2026
2158130
feat: add zustand dependency and persistence key contract
CD-Z Apr 7, 2026
c4d44a9
refactor: extract bootstrap data loading into reusable service
CD-Z Mar 25, 2026
5c3d97b
refactor: move chapter mutations into store-ready action helpers
CD-Z Mar 25, 2026
f966df4
feat: add zustand novel store with cache and core actions
CD-Z Mar 25, 2026
e9e4ed8
refactor: bridge novel persistence contracts for migration safety
CD-Z Mar 25, 2026
98bf1eb
refactor: migrate NovelScreen domain flows to zustand selectors
CD-Z Mar 25, 2026
e3ea9bc
refactor: migrate NovelScreenList to selector-based store access
CD-Z Apr 13, 2026
b7dc2cc
refactor: move reader chapter flows onto store boundaries
CD-Z Mar 25, 2026
e68fafe
refactor: decouple useNovelSettings from broad context domain state
CD-Z Mar 25, 2026
5f46184
refactor: align migrateNovel with stable persistence contracts
CD-Z Mar 25, 2026
136211e
refactor: cut novel-reader consumers to store-only context boundary
CD-Z Apr 7, 2026
3ce9caf
refactor: retire legacy useNovel and route cache cleanup export
CD-Z Apr 13, 2026
fa24c89
test: update suites for store-only context boundary cutover
CD-Z Mar 25, 2026
34bd062
test: modernize store-era mocks and add contract coverage
CD-Z Apr 7, 2026
6fb017c
test: finalize Task-15 sweep—remove dead useNovelData and lint clear …
CD-Z Mar 25, 2026
1b14eec
remove imports from NovelScreen
CD-Z Mar 26, 2026
e0612ac
reworked ai output
CD-Z Mar 27, 2026
0c05734
improvements
CD-Z Mar 28, 2026
cef8900
implemented synchronus novel and chapter fetch
CD-Z Apr 10, 2026
d6de86d
refactor tests
CD-Z Apr 11, 2026
27ffbe8
fix db tests
CD-Z Apr 13, 2026
a452b77
Update remaining tests.
CD-Z Apr 11, 2026
7a00633
Harden chapter actions and bootstrap flows
CD-Z Apr 13, 2026
9521908
Only count filtered chapters
CD-Z Apr 13, 2026
9712767
improved chapter insert speed by optimizing triggers
CD-Z Apr 16, 2026
660a41b
Improved the batching function
CD-Z Apr 16, 2026
627233c
Added drizzle support to dbManager.batch
CD-Z Apr 16, 2026
1c57171
removed better-sqlite3 for testing
CD-Z Apr 16, 2026
6319571
fix tests
CD-Z Apr 17, 2026
167f77c
Update updateNovelChapters fucntion
CD-Z Apr 17, 2026
fb162db
reverse read filter
CD-Z Apr 18, 2026
aa73d0f
fix snackbar
CD-Z Apr 18, 2026
7e62668
fixed page bottomsheet
CD-Z Apr 18, 2026
ef6521f
resolved paged novels showing wrong chapter number on opening
CD-Z Apr 18, 2026
8fa0f09
use openPage instead of setPageIndex in chapterDrawer
CD-Z Apr 18, 2026
5bbd4b0
fix lint & tests
CD-Z Apr 19, 2026
190fd99
fix type issues
CD-Z Apr 20, 2026
035bbd0
fix various smaller issues
CD-Z Apr 20, 2026
89be147
updated novel restore
CD-Z May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions __mocks__/database.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
jest.mock('@database/queries/NovelQueries', () => ({
getNovelById: jest.fn(),
getNovelByPath: jest.fn(),
deleteCachedNovels: jest.fn(),
getCachedNovels: jest.fn(),
Expand Down Expand Up @@ -30,7 +31,9 @@ jest.mock('@database/queries/ChapterQueries', () => ({
insertChapters: jest.fn(),
getCustomPages: jest.fn(),
getChapterCount: jest.fn(),
getChapterCountSync: jest.fn(),
getPageChaptersBatched: jest.fn(),
getNovelChaptersSync: jest.fn(),
getFirstUnreadChapter: jest.fn(),
updateChapterProgress: jest.fn(),
}));
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@
"react-native-worklets": "^0.8.1",
"react-native-zip-archive": "^7.0.2",
"sanitize-html": "^2.17.2",
"urlencode": "^2.0.0"
"urlencode": "^2.0.0",
"zustand": "^5.0.12"
Comment on lines +121 to +122
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

What is the latest version of zustand npm package?

💡 Result:

The latest version of the zustand npm package is 5.0.12.

Citations:


🌐 Web query:

Zustand 5.0.12 React 19 compatibility

💡 Result:

Zustand 5.0.12 is not officially compatible with React 19. The peer dependency for React is specified as ">=18.0.0", which does not include React 19. Official documentation and changelogs for v5.0.12 do not mention React 19 support or updates to peer dependencies. Early discussions (e.g., Nov 2024) show peer dependency conflicts with React 19 RCs when using npm strict mode, requiring --force or --legacy-peer-deps. While some tools like DepFixer claim compatibility for earlier 5.x versions and Zustand works in practice due to minimal React API usage, strict compatibility requires peer deps alignment, which is absent. For React 19, follow best practices like using selectors with 'use' prefix and useShallow for destructuring to work with React Compiler.

Citations:


Zustand 5.0.12 is not officially compatible with React 19.

Zustand 5.0.12 is the latest stable version, but its peer dependency specifies >=18.0.0, explicitly excluding React 19. Using it with React 19 will require --force or --legacy-peer-deps flags during installation. If this project targets React 19, either upgrade Zustand once React 19 support is released or retain React 18 compatibility.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 117 - 118, The package.json currently lists
"zustand": "^5.0.12" which declares a peer dependency excluding React 19; update
package configuration to avoid broken installs by either (A) pinning the project
React peer/dependency to a 18.x range (so keep "zustand": "^5.0.12") or (B) if
you must target React 19, remove or delay the dependency upgrade and add a TODO
to upgrade "zustand" to a version that advertises React 19 support once released
(or use an explicit override/resolution only as a temporary measure); adjust the
package.json "react"/"react-dom" entries or add a comment/issue referencing
"zustand": "^5.0.12" to document the chosen approach.

},
"devDependencies": {
"@babel/core": "^7.29.0",
Expand All @@ -145,7 +146,7 @@
"@typescript-eslint/parser": "^8.58.0",
"babel-plugin-module-resolver": "^5.0.3",
"babel-plugin-react-compiler": "^1.0.0",
"better-sqlite3": "^12.8.0",
"better-sqlite3": "^12.9.0",
"drizzle-kit": "1.0.0-beta.20",
"eslint": "^8.57.1",
"eslint-plugin-eslint-comments": "^3.2.0",
Expand Down
43 changes: 35 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

139 changes: 36 additions & 103 deletions src/database/__tests__/db.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,9 @@
import Database from 'better-sqlite3';
import { open, type DB } from '@op-engineering/op-sqlite';
import { drizzle } from 'drizzle-orm/op-sqlite';
import { migrate } from 'drizzle-orm/op-sqlite/migrator';
import migrations from '../../../drizzle/migrations';
import { schema } from '@database/schema';

jest.mock('@op-engineering/op-sqlite', () => ({
__esModule: true,
open: jest.fn(() => ({
execute: jest.fn().mockResolvedValue({ rows: [] }),
executeAsync: jest.fn().mockResolvedValue({ rows: [] }),
executeSync: jest.fn().mockReturnValue({ rows: [] }),
executeRawAsync: jest.fn().mockResolvedValue([]),
executeBatch: jest.fn().mockResolvedValue(undefined),
flushPendingReactiveQueries: jest.fn(),
reactiveExecute: jest.fn(() => () => undefined),
})),
}));

import { runDatabaseBootstrap } from '@database/db';

const MIGRATION_STATEMENTS = [
Expand Down Expand Up @@ -80,84 +67,26 @@ const MIGRATION_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS repository_url_unique ON Repository (url)`,
];

const createExecutor = (sqlite: Database.Database) => ({
const createExecutor = (sqlite: DB) => ({
executeSync: (sql: string, params?: unknown[]) => {
if (params && params.length) {
const stmt = sqlite.prepare(sql);
stmt.run(params as any[]);
return;
}
sqlite.exec(sql);
sqlite.executeSync(sql, params as any[]);
},
});

const createOpSqliteAdapter = (sqlite: Database.Database) => {
return {
execute: async (sql: string, params?: unknown[]) => {
const stmt = sqlite.prepare(sql);
const rows =
params && params.length ? stmt.all(params as any[]) : stmt.all();
return {
rows: {
_array: rows.map(row =>
Object.values(row as Record<string, unknown>),
),
},
};
},
executeSync: (sql: string, params?: unknown[]) => {
const stmt = sqlite.prepare(sql);
const result =
params && params.length ? stmt.run(params as any[]) : stmt.run();
return { rows: [], rowsAffected: result.changes ?? 0 };
},
executeAsync: async (sql: string, params?: unknown[]) => {
const stmt = sqlite.prepare(sql);
const result =
params && params.length ? stmt.run(params as any[]) : stmt.run();
return { rows: [], rowsAffected: result.changes ?? 0 };
},
executeRawAsync: async (sql: string, params?: unknown[]) => {
const stmt = sqlite.prepare(sql).raw();
const rows =
params && params.length ? stmt.all(params as any[]) : stmt.all();
return rows as unknown[][];
},
executeBatch: async (
commands: Array<[string, unknown[] | unknown[][]]>,
) => {
const transaction = sqlite.transaction((cmds: typeof commands) => {
for (const cmd of cmds) {
const stmt = sqlite.prepare(cmd[0]);
if (Array.isArray(cmd[1])) {
for (const arg of cmd[1]) {
stmt.run(arg as any[]);
}
} else {
stmt.run(cmd[1] as any[]);
}
}
});
transaction(commands);
},
flushPendingReactiveQueries: () => undefined,
reactiveExecute: () => () => undefined,
};
};

describe('new database initialization', () => {
it('creates schema, triggers, and default data', async () => {
const sqlite = new Database(':memory:');
const sqlite = open({ name: ':memory:' });
(sqlite as any).executeAsync ??= sqlite.execute;
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
try {
const adapter = createOpSqliteAdapter(sqlite);
const drizzleDb = drizzle(adapter, { schema });
const drizzleDb = drizzle(sqlite, { schema });

await migrate(drizzleDb, migrations);
runDatabaseBootstrap(createExecutor(sqlite));

const tables = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as Array<{ name: string }>;
const tables = sqlite.executeSync(
"SELECT name FROM sqlite_master WHERE type='table'",
).rows as Array<{ name: string }>;
const tableNames = tables.map(table => table.name);
expect(tableNames).toEqual(
expect.arrayContaining([
Expand All @@ -169,9 +98,9 @@ describe('new database initialization', () => {
]),
);

const triggers = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='trigger'")
.all() as Array<{ name: string }>;
const triggers = sqlite.executeSync(
"SELECT name FROM sqlite_master WHERE type='trigger'",
).rows as Array<{ name: string }>;
const triggerNames = triggers.map(trigger => trigger.name);
expect(triggerNames).toEqual(
expect.arrayContaining([
Expand All @@ -182,9 +111,9 @@ describe('new database initialization', () => {
]),
);

const categories = sqlite
.prepare('SELECT id, name FROM Category ORDER BY id')
.all() as Array<{ id: number; name: string }>;
const categories = sqlite.executeSync(
'SELECT id, name FROM Category ORDER BY id',
).rows as Array<{ id: number; name: string }>;
expect(categories.map(category => category.id)).toEqual([1, 2]);
} finally {
sqlite.close();
Expand All @@ -194,20 +123,23 @@ describe('new database initialization', () => {

describe('runDatabaseBootstrap', () => {
it('applies pragmas, triggers, and default categories', () => {
const sqlite = new Database(':memory:');
const sqlite = open({ name: ':memory:' });
(sqlite as any).executeAsync ??= sqlite.execute;
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
try {
for (const statement of MIGRATION_STATEMENTS) {
sqlite.exec(statement.trim());
sqlite.executeSync(statement.trim());
}

sqlite.executeSync('PRAGMA journal_mode = WAL');
runDatabaseBootstrap(createExecutor(sqlite));

const journalMode = sqlite.pragma('journal_mode', { simple: true });
const journalMode = sqlite.executeRawSync('PRAGMA journal_mode')[0]?.[0];
expect(['wal', 'memory']).toContain(String(journalMode).toLowerCase());

const triggers = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='trigger'")
.all() as Array<{ name: string }>;
const triggers = sqlite.executeSync(
"SELECT name FROM sqlite_master WHERE type='trigger'",
).rows as Array<{ name: string }>;
const triggerNames = triggers.map(trigger => trigger.name);
expect(triggerNames).toEqual(
expect.arrayContaining([
Expand All @@ -218,9 +150,9 @@ describe('runDatabaseBootstrap', () => {
]),
);

const categories = sqlite
.prepare('SELECT id, name FROM Category ORDER BY id')
.all() as Array<{ id: number; name: string }>;
const categories = sqlite.executeSync(
'SELECT id, name FROM Category ORDER BY id',
).rows as Array<{ id: number; name: string }>;
expect(categories.map(category => category.id)).toEqual([1, 2]);
expect(categories.map(category => category.name)).toEqual([
'categories.default',
Expand All @@ -234,19 +166,20 @@ describe('runDatabaseBootstrap', () => {

describe('production migrations', () => {
it('can run after test schema exists', async () => {
const sqlite = new Database(':memory:');
const sqlite = open({ name: ':memory:' });
(sqlite as any).executeAsync ??= sqlite.execute;
(sqlite as any).executeRawAsync ??= sqlite.executeRaw;
try {
for (const statement of MIGRATION_STATEMENTS) {
sqlite.exec(statement.trim());
sqlite.executeSync(statement.trim());
}

const adapter = createOpSqliteAdapter(sqlite);
const drizzleDb = drizzle(adapter, { schema });
const drizzleDb = drizzle(sqlite, { schema });
await migrate(drizzleDb, migrations);

const tables = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as Array<{ name: string }>;
const tables = sqlite.executeSync(
"SELECT name FROM sqlite_master WHERE type='table'",
).rows as Array<{ name: string }>;
const tableNames = tables.map(table => table.name);
expect(tableNames).toEqual(
expect.arrayContaining([
Expand Down
4 changes: 4 additions & 0 deletions src/database/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ const populateDatabase = (executor: SqlExecutor) => {

const createDbTriggers = (executor: SqlExecutor) => {
console.log('Creating database triggers');
executor.executeSync('DROP TRIGGER IF EXISTS update_novel_stats');
executor.executeSync('DROP TRIGGER IF EXISTS update_novel_stats_on_update');
executor.executeSync('DROP TRIGGER IF EXISTS update_novel_stats_on_delete');
executor.executeSync('DROP TRIGGER IF EXISTS add_category');
executor.executeSync(createCategoryTriggerQuery);
executor.executeSync(createNovelTriggerQueryDelete);
executor.executeSync(createNovelTriggerQueryInsert);
Expand Down
19 changes: 18 additions & 1 deletion src/database/manager/manager.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// db-manager.types.ts
import type { SQLiteTransaction, TablesRelationalConfig } from 'drizzle-orm';
import type {
SQLiteTransaction,
TablesRelationalConfig,
Placeholder,
} from 'drizzle-orm';
import { SQLitePreparedQuery } from 'drizzle-orm/sqlite-core';

// Define the TransactionParameter type based on your DrizzleDb
export type TransactionParameter = SQLiteTransaction<
Expand All @@ -14,6 +19,18 @@ export type TransactionParameter = SQLiteTransaction<
* This contract ensures consistent documentation and type safety across the application.
*/
export interface IDbManager {
/**
* Efficiently executes a Drizzle query for multiple data rows using
* op-sqlite executeBatch under the hood.
*/
batch<T extends Record<string, unknown>>(
data: T[],
fn: (
tx: TransactionParameter,
ph: (arg: Extract<keyof T, string>) => Placeholder,
) => SQLitePreparedQuery<any>,
): Promise<void>;

/**
* Creates a subquery that defines a temporary named result set as a CTE.
*
Expand Down
Loading
Loading