Skip to content

Commit f8a42f0

Browse files
tomymaritanoclaude
andauthored
refactor(storage-sqlite): extract noteMapping helpers from SQLiteNoteRepository (#282)
## Summary **Phase 1 of the SQLiteNoteRepository split.** Pure helpers come out to their own file so future sub-repositories (sync, tag, archive) can reuse them without inheriting from the 1000-line class. | Metric | Before | After | |---|---|---| | \`SQLiteNoteRepository.ts\` | 1121 lines | **1038 lines** (-7%) | | \`noteMapping.ts\` | — | 133 lines (new) | ## Extracted | Symbol | Kind | Notes | |---|---|---| | \`NoteRow\`, \`TagRow\`, \`TagWithColorRow\`, \`BacklinkInfo\` | Row types | Re-exported by SQLiteNoteRepository so external imports keep working | | \`rowToNote(row, tags) -> Note\` | Pure mapper | Reconstructs a domain Note from a SQLite row + its tags | | \`prepareFtsQuery(input) -> string\` | Pure helper | FTS5 query escaper + tokenizer | | \`archivedConditionSql(filter, alias) -> string\` | Pure helper | SQL fragment for archived filtering | Call sites swapped from \`this.<helper>()\` to plain function imports. **The public class signature is unchanged** — \`BacklinkInfo\` is re-exported so external consumers (e.g. \`apps/desktop/src/main/handlers/types.ts\`) keep working without edits. ## What this PR DELIBERATELY does NOT do - **Extract sync methods** (\`getPendingChanges\` through \`getSyncHistory\` ~430 lines) into a \`SQLiteNoteSyncRepository\`. Those share state — tag queries, transactions, FTS sync triggers — with the main class in ways that need real-DB integration coverage to refactor safely. The helpers extracted here are the foundation: a follow-up PR can build the sync sub-repo on top of them without touching the helpers again. - **Extract tag methods** (\`setManualTags\`, \`renameTag\`, \`getAllTagsWithColors\`, etc.) for the same reason. The audit aspired to a 4-way split (NoteCrudRepository + NoteTagRepository + NoteArchiveRepository + NoteSyncRepository). That remains the destination. This PR ships the **foundation** that makes those splits low-risk; each can ride its own PR with focused review. ## Test plan - [x] \`pnpm -r typecheck\` — green - [x] \`pnpm test\` — 17/17 packages - [ ] Manual: launch the desktop, exercise notes CRUD + search + tag operations. Behavior should be identical (no observable change). ## Stack context Stacked on **PR-F3 wiring** (#281) → PR-Knip-2 (#280) → PR-Knip-1 (#279) → PR-G (#278) → PR-E (#277) → ... down to PR-B (#265). **18 PRs deep.** 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0ec48b3 commit f8a42f0

2 files changed

Lines changed: 154 additions & 113 deletions

File tree

packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts

Lines changed: 24 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,23 @@
44
* Implements the ExtendedNoteRepository interface from @readied/storage-core
55
*/
66

7-
import type {
8-
ExtendedNoteRepository,
9-
ListNotesOptions,
10-
ArchivedFilter,
11-
} from '@readied/storage-core';
12-
import {
13-
type Note,
14-
type NoteId,
15-
type NoteStatus,
16-
type Tag,
17-
type Timestamp,
18-
createNote,
19-
createNoteId,
20-
createNotebookId,
21-
createTag,
22-
DEFAULT_NOTE_STATUS,
23-
} from '@readied/core';
7+
import type { ExtendedNoteRepository, ListNotesOptions } from '@readied/storage-core';
8+
import { type Note, type NoteId, type Tag, createNoteId, createTag } from '@readied/core';
249
import { extractWikilinks } from '@readied/wikilinks';
2510
import type { DatabaseConnection } from '../database.js';
11+
import {
12+
rowToNote,
13+
prepareFtsQuery,
14+
archivedConditionSql,
15+
type NoteRow,
16+
type TagRow,
17+
type TagWithColorRow,
18+
type BacklinkInfo,
19+
} from './noteMapping.js';
20+
21+
// Re-export public types so external imports (e.g. desktop's handlers/types.ts)
22+
// keep working unchanged.
23+
export type { BacklinkInfo };
2624

2725
/** Sync history entry returned by getSyncHistory */
2826
export interface SyncHistoryEntry {
@@ -42,37 +40,6 @@ export interface SyncHistoryEntry {
4240
errorMessage: string | null;
4341
}
4442

45-
/** Row type from SQLite */
46-
interface NoteRow {
47-
id: string;
48-
notebook_id: string;
49-
content: string;
50-
title: string;
51-
created_at: string;
52-
updated_at: string;
53-
word_count: number;
54-
archived_at: string | null;
55-
is_pinned: number; // SQLite stores booleans as 0/1
56-
is_deleted: number;
57-
status: string;
58-
}
59-
60-
interface TagRow {
61-
name: string;
62-
}
63-
64-
interface TagWithColorRow {
65-
name: string;
66-
color: string | null;
67-
}
68-
69-
/** Backlink information for UI display */
70-
export interface BacklinkInfo {
71-
noteId: string;
72-
noteTitle: string;
73-
targetRef: string;
74-
}
75-
7643
/** SQLite implementation of ExtendedNoteRepository */
7744
export class SQLiteNoteRepository implements ExtendedNoteRepository {
7845
constructor(private readonly db: DatabaseConnection) {}
@@ -90,7 +57,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository {
9057
if (!row) return null;
9158

9259
const tags = this.getTagsForNote(id);
93-
return this.rowToNote(row, tags);
60+
return rowToNote(row, tags);
9461
}
9562

9663
/** Save a note (insert or update) */
@@ -156,7 +123,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository {
156123
title: 'title',
157124
}[sortBy];
158125

159-
const archivedCondition = this.getArchivedCondition(archived, 'n');
126+
const archivedCondition = archivedConditionSql(archived, 'n');
160127
let sql: string;
161128
let params: (string | number)[];
162129

@@ -189,7 +156,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository {
189156

190157
return rows.map(row => {
191158
const tags = this.getTagsForNote(createNoteId(row.id));
192-
return this.rowToNote(row, tags);
159+
return rowToNote(row, tags);
193160
});
194161
}
195162

@@ -207,7 +174,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository {
207174
const archivedCondition = includeArchived ? '' : 'AND n.archived_at IS NULL';
208175

209176
// Prepare FTS5 query: escape special chars, add prefix matching
210-
const ftsQuery = this.prepareFtsQuery(trimmedQuery);
177+
const ftsQuery = prepareFtsQuery(trimmedQuery);
211178

212179
const stmt = this.db.prepare<NoteRow>(`
213180
SELECT n.id, n.notebook_id, n.content, n.title, n.created_at, n.updated_at,
@@ -223,26 +190,11 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository {
223190

224191
return rows.map(row => {
225192
const tags = this.getTagsForNote(createNoteId(row.id));
226-
return this.rowToNote(row, tags);
193+
return rowToNote(row, tags);
227194
});
228195
}
229196

230-
/** Prepare query string for FTS5 MATCH syntax */
231-
private prepareFtsQuery(query: string): string {
232-
// Escape FTS5 special characters: " * ^ - OR AND NOT ( )
233-
const escaped = query.replace(/["*^()]/g, ' ').trim();
234-
235-
// Split into terms and add prefix matching for partial word search
236-
const terms = escaped.split(/\s+/).filter(t => t.length > 0);
237-
238-
if (terms.length === 0) {
239-
return '""'; // Empty search
240-
}
241-
242-
// Use OR between terms with prefix matching
243-
// Each term becomes "term"* for prefix matching
244-
return terms.map(t => `"${t}"*`).join(' OR ');
245-
}
197+
// prepareFtsQuery moved to noteMapping.ts
246198

247199
/** Get total count of notes */
248200
async count(includeArchived: boolean = false): Promise<number> {
@@ -283,17 +235,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository {
283235

284236
// Private helpers
285237

286-
private getArchivedCondition(filter: ArchivedFilter, tableAlias: string = ''): string {
287-
const prefix = tableAlias ? `${tableAlias}.` : '';
288-
switch (filter) {
289-
case 'active':
290-
return `AND ${prefix}archived_at IS NULL`;
291-
case 'archived':
292-
return `AND ${prefix}archived_at IS NOT NULL`;
293-
case 'all':
294-
return '';
295-
}
296-
}
238+
// getArchivedCondition moved to noteMapping.archivedConditionSql
297239

298240
private getTagsForNote(noteId: NoteId): Tag[] {
299241
const stmt = this.db.prepare<TagRow>(`
@@ -478,38 +420,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository {
478420
}
479421
}
480422

481-
private rowToNote(row: NoteRow, tags: Tag[]): Note {
482-
// Reconstruct note from stored data with structural title
483-
const note = createNote({
484-
id: createNoteId(row.id),
485-
notebookId: createNotebookId(row.notebook_id),
486-
title: row.title, // Structural title from DB
487-
content: row.content,
488-
createdAt: row.created_at as Timestamp,
489-
isPinned: row.is_pinned === 1,
490-
isDeleted: row.is_deleted === 1,
491-
status: (row.status as NoteStatus) || DEFAULT_NOTE_STATUS,
492-
});
493-
494-
// Return note with stored metadata
495-
return {
496-
...note,
497-
notebookId: createNotebookId(row.notebook_id),
498-
title: row.title, // Ensure structural title is set
499-
isPinned: row.is_pinned === 1,
500-
isDeleted: row.is_deleted === 1,
501-
status: (row.status as NoteStatus) || DEFAULT_NOTE_STATUS,
502-
metadata: {
503-
...note.metadata,
504-
title: row.title,
505-
createdAt: row.created_at as Timestamp,
506-
updatedAt: row.updated_at as Timestamp,
507-
tags,
508-
wordCount: row.word_count,
509-
archivedAt: row.archived_at as Timestamp | null,
510-
},
511-
};
512-
}
423+
// rowToNote moved to noteMapping.ts
513424

514425
// ═══════════════════════════════════════════════════════════════════════════
515426
// Links (Wikilinks / Backlinks)
@@ -737,7 +648,7 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository {
737648
return rows.map(row => {
738649
const tags = this.getTagsForNote(createNoteId(row.id));
739650
return {
740-
note: this.rowToNote(row, tags),
651+
note: rowToNote(row, tags),
741652
localVersion: row.local_version,
742653
lastSyncedAt: row.last_synced_at,
743654
};
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Row → Note mapping helpers and shared row types.
3+
*
4+
* Pure functions extracted from SQLiteNoteRepository so future
5+
* sync / tag / archive sub-repositories can reuse them without
6+
* depending on the main repo class.
7+
*/
8+
9+
import {
10+
type Note,
11+
type NoteStatus,
12+
type Tag,
13+
type Timestamp,
14+
createNote,
15+
createNoteId,
16+
createNotebookId,
17+
DEFAULT_NOTE_STATUS,
18+
} from '@readied/core';
19+
import type { ArchivedFilter } from '@readied/storage-core';
20+
21+
/** Row shape returned by `SELECT * FROM notes` */
22+
export interface NoteRow {
23+
id: string;
24+
notebook_id: string;
25+
content: string;
26+
title: string;
27+
created_at: string;
28+
updated_at: string;
29+
word_count: number;
30+
archived_at: string | null;
31+
is_pinned: number; // SQLite stores booleans as 0/1
32+
is_deleted: number;
33+
status: string;
34+
}
35+
36+
/** Row shape for tag joins (just the tag name) */
37+
export interface TagRow {
38+
name: string;
39+
}
40+
41+
/** Row shape for tags with their color metadata */
42+
export interface TagWithColorRow {
43+
name: string;
44+
color: string | null;
45+
}
46+
47+
/** Backlink information surfaced to the UI */
48+
export interface BacklinkInfo {
49+
noteId: string;
50+
noteTitle: string;
51+
targetRef: string;
52+
}
53+
54+
/**
55+
* Reconstruct a domain Note from a SQLite row plus its tags.
56+
*
57+
* The row carries the *stored* (structural) title — the markdown-derived
58+
* "display" title lives elsewhere. We reuse `createNote` to get fresh
59+
* metadata defaults, then overlay the persisted values so that
60+
* createdAt / updatedAt / wordCount / archivedAt survive the roundtrip.
61+
*/
62+
export function rowToNote(row: NoteRow, tags: Tag[]): Note {
63+
const note = createNote({
64+
id: createNoteId(row.id),
65+
notebookId: createNotebookId(row.notebook_id),
66+
title: row.title,
67+
content: row.content,
68+
createdAt: row.created_at as Timestamp,
69+
isPinned: row.is_pinned === 1,
70+
isDeleted: row.is_deleted === 1,
71+
status: (row.status as NoteStatus) || DEFAULT_NOTE_STATUS,
72+
});
73+
74+
return {
75+
...note,
76+
notebookId: createNotebookId(row.notebook_id),
77+
title: row.title,
78+
isPinned: row.is_pinned === 1,
79+
isDeleted: row.is_deleted === 1,
80+
status: (row.status as NoteStatus) || DEFAULT_NOTE_STATUS,
81+
metadata: {
82+
...note.metadata,
83+
title: row.title,
84+
createdAt: row.created_at as Timestamp,
85+
updatedAt: row.updated_at as Timestamp,
86+
tags,
87+
wordCount: row.word_count,
88+
archivedAt: row.archived_at as Timestamp | null,
89+
},
90+
};
91+
}
92+
93+
/**
94+
* Build an FTS5 MATCH clause from a free-form user query.
95+
*
96+
* 1. Strip FTS5 special chars (" * ^ ( )) — we'll add our own.
97+
* 2. Tokenize on whitespace.
98+
* 3. Quote each token (defends against tokens that look like FTS keywords)
99+
* and append `*` for prefix-matching.
100+
* 4. Join with OR — any token match counts.
101+
*
102+
* Empty / all-whitespace input returns `""`, which FTS5 treats as "no
103+
* results" rather than throwing.
104+
*/
105+
export function prepareFtsQuery(input: string): string {
106+
const escaped = input.replace(/["*^()]/g, ' ').trim();
107+
const terms = escaped.split(/\s+/).filter(t => t.length > 0);
108+
if (terms.length === 0) return '""';
109+
return terms.map(t => `"${t}"*`).join(' OR ');
110+
}
111+
112+
/**
113+
* Build a SQL fragment that filters by archived state.
114+
*
115+
* Returns either an empty string (no filter) or a SQL chunk starting
116+
* with `AND`. Caller is responsible for the WHERE.
117+
*
118+
* @param tableAlias prefix without trailing dot, e.g. `n` → emits `n.archived_at`
119+
*/
120+
export function archivedConditionSql(filter: ArchivedFilter, tableAlias: string = ''): string {
121+
const prefix = tableAlias ? `${tableAlias}.` : '';
122+
switch (filter) {
123+
case 'active':
124+
return `AND ${prefix}archived_at IS NULL`;
125+
case 'archived':
126+
return `AND ${prefix}archived_at IS NOT NULL`;
127+
case 'all':
128+
return '';
129+
}
130+
}

0 commit comments

Comments
 (0)