···11+// Database module for direct SQLite access
22+// Replaces TinyBase with better-sqlite3
33+44+import Database from 'better-sqlite3';
55+import { createTableStatements, tableNames } from './schema-sql.js';
66+77+let db = null;
88+99+/**
1010+ * Initialize the SQLite database
1111+ * @param {string} dbPath - Path to the database file
1212+ * @returns {Database} The database instance
1313+ */
1414+export const initDatabase = (dbPath) => {
1515+ console.log('main', 'initializing database at:', dbPath);
1616+1717+ db = new Database(dbPath);
1818+1919+ // Enable WAL mode for better concurrent access
2020+ db.pragma('journal_mode = WAL');
2121+2222+ // Create tables and indexes
2323+ db.exec(createTableStatements);
2424+2525+ // Migrate from TinyBase if needed
2626+ migrateTinyBaseData();
2727+2828+ console.log('main', 'database initialized successfully');
2929+ return db;
3030+};
3131+3232+/**
3333+ * One-time migration from TinyBase internal format to direct tables
3434+ */
3535+const migrateTinyBaseData = () => {
3636+ // Check if tinybase table exists
3737+ const tinybaseExists = db.prepare(`
3838+ SELECT name FROM sqlite_master WHERE type='table' AND name='tinybase'
3939+ `).get();
4040+4141+ if (!tinybaseExists) {
4242+ return; // No TinyBase data to migrate
4343+ }
4444+4545+ // Check if we already migrated (addresses table has data)
4646+ const existingData = db.prepare('SELECT COUNT(*) as count FROM addresses').get();
4747+ if (existingData.count > 0) {
4848+ console.log('main', 'TinyBase data already migrated, skipping');
4949+ return;
5050+ }
5151+5252+ console.log('main', 'Migrating TinyBase data to direct tables...');
5353+5454+ try {
5555+ // Read TinyBase data (stored as JSON in a single row)
5656+ const tinybaseRow = db.prepare('SELECT * FROM tinybase').get();
5757+ if (!tinybaseRow) {
5858+ console.log('main', 'No TinyBase data found');
5959+ return;
6060+ }
6161+6262+ // TinyBase stores data in the second column as JSON array [tables, values]
6363+ const rawData = Object.values(tinybaseRow)[1];
6464+ if (!rawData) {
6565+ console.log('main', 'TinyBase data is empty');
6666+ return;
6767+ }
6868+6969+ const [tables] = JSON.parse(rawData);
7070+ if (!tables) {
7171+ console.log('main', 'No tables in TinyBase data');
7272+ return;
7373+ }
7474+7575+ // Migrate each table
7676+ const tablesToMigrate = ['addresses', 'visits', 'tags', 'address_tags', 'extension_settings', 'extensions', 'content', 'blobs', 'scripts_data', 'feeds'];
7777+7878+ for (const tableName of tablesToMigrate) {
7979+ const tableData = tables[tableName];
8080+ if (!tableData || typeof tableData !== 'object') continue;
8181+8282+ const entries = Object.entries(tableData);
8383+ if (entries.length === 0) continue;
8484+8585+ console.log('main', ` Migrating ${entries.length} rows from ${tableName}`);
8686+8787+ for (const [id, row] of entries) {
8888+ try {
8989+ const fullRow = { id, ...row };
9090+ const columns = Object.keys(fullRow);
9191+ const placeholders = columns.map(() => '?').join(', ');
9292+ const values = columns.map(col => fullRow[col]);
9393+9494+ db.prepare(`INSERT OR IGNORE INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`).run(...values);
9595+ } catch (err) {
9696+ console.error('main', ` Error migrating row ${id} in ${tableName}:`, err.message);
9797+ }
9898+ }
9999+ }
100100+101101+ // Drop the tinybase table after successful migration
102102+ db.exec('DROP TABLE IF EXISTS tinybase');
103103+ console.log('main', 'TinyBase migration complete, removed tinybase table');
104104+105105+ } catch (error) {
106106+ console.error('main', 'TinyBase migration failed:', error.message);
107107+ }
108108+};
109109+110110+/**
111111+ * Get the database instance
112112+ * @returns {Database|null}
113113+ */
114114+export const getDb = () => db;
115115+116116+/**
117117+ * Close the database connection
118118+ */
119119+export const closeDatabase = () => {
120120+ if (db) {
121121+ db.close();
122122+ db = null;
123123+ console.log('main', 'database closed');
124124+ }
125125+};
126126+127127+// Helper functions
128128+129129+/**
130130+ * Generate a unique ID with optional prefix
131131+ * @param {string} prefix - Prefix for the ID
132132+ * @returns {string}
133133+ */
134134+export const generateId = (prefix = 'id') => {
135135+ return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
136136+};
137137+138138+/**
139139+ * Get current timestamp in milliseconds
140140+ * @returns {number}
141141+ */
142142+export const now = () => Date.now();
143143+144144+/**
145145+ * Parse a URL into components
146146+ * @param {string} uri - The URL to parse
147147+ * @returns {{protocol: string, domain: string, path: string}}
148148+ */
149149+export const parseUrl = (uri) => {
150150+ try {
151151+ const url = new URL(uri);
152152+ return {
153153+ protocol: url.protocol.replace(':', ''),
154154+ domain: url.hostname,
155155+ path: url.pathname + url.search + url.hash
156156+ };
157157+ } catch (e) {
158158+ return {
159159+ protocol: 'unknown',
160160+ domain: uri,
161161+ path: ''
162162+ };
163163+ }
164164+};
165165+166166+/**
167167+ * Normalize a URL for consistent storage
168168+ * @param {string} uri - The URL to normalize
169169+ * @returns {string}
170170+ */
171171+export const normalizeUrl = (uri) => {
172172+ if (!uri) return uri;
173173+174174+ try {
175175+ const url = new URL(uri);
176176+177177+ // Remove trailing slash from path (except for root)
178178+ if (url.pathname !== '/' && url.pathname.endsWith('/')) {
179179+ url.pathname = url.pathname.slice(0, -1);
180180+ }
181181+182182+ // Remove default ports
183183+ if ((url.protocol === 'http:' && url.port === '80') ||
184184+ (url.protocol === 'https:' && url.port === '443')) {
185185+ url.port = '';
186186+ }
187187+188188+ // Sort query parameters for consistency
189189+ if (url.search) {
190190+ const params = new URLSearchParams(url.search);
191191+ const sortedParams = new URLSearchParams([...params.entries()].sort());
192192+ url.search = sortedParams.toString();
193193+ }
194194+195195+ return url.toString();
196196+ } catch (e) {
197197+ return uri;
198198+ }
199199+};
200200+201201+/**
202202+ * Check if a table name is valid
203203+ * @param {string} tableName - The table name to validate
204204+ * @returns {boolean}
205205+ */
206206+export const isValidTable = (tableName) => {
207207+ return tableNames.includes(tableName);
208208+};
209209+210210+/**
211211+ * Get all rows from a table as an object keyed by ID
212212+ * @param {string} tableName - The table name
213213+ * @returns {Object}
214214+ */
215215+export const getTableAsObject = (tableName) => {
216216+ if (!isValidTable(tableName)) {
217217+ throw new Error(`Invalid table name: ${tableName}`);
218218+ }
219219+220220+ const rows = db.prepare(`SELECT * FROM ${tableName}`).all();
221221+ const result = {};
222222+ for (const row of rows) {
223223+ result[row.id] = row;
224224+ }
225225+ return result;
226226+};
227227+228228+/**
229229+ * Get a single row by ID
230230+ * @param {string} tableName - The table name
231231+ * @param {string} id - The row ID
232232+ * @returns {Object|undefined}
233233+ */
234234+export const getRow = (tableName, id) => {
235235+ if (!isValidTable(tableName)) {
236236+ throw new Error(`Invalid table name: ${tableName}`);
237237+ }
238238+239239+ return db.prepare(`SELECT * FROM ${tableName} WHERE id = ?`).get(id);
240240+};
241241+242242+/**
243243+ * Insert or replace a row
244244+ * @param {string} tableName - The table name
245245+ * @param {string} id - The row ID
246246+ * @param {Object} data - The row data
247247+ */
248248+export const setRow = (tableName, id, data) => {
249249+ if (!isValidTable(tableName)) {
250250+ throw new Error(`Invalid table name: ${tableName}`);
251251+ }
252252+253253+ const row = { id, ...data };
254254+ const columns = Object.keys(row);
255255+ const placeholders = columns.map(() => '?').join(', ');
256256+ const values = columns.map(col => row[col]);
257257+258258+ const sql = `INSERT OR REPLACE INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`;
259259+ db.prepare(sql).run(...values);
260260+};
261261+262262+/**
263263+ * Delete a row by ID
264264+ * @param {string} tableName - The table name
265265+ * @param {string} id - The row ID
266266+ */
267267+export const deleteRow = (tableName, id) => {
268268+ if (!isValidTable(tableName)) {
269269+ throw new Error(`Invalid table name: ${tableName}`);
270270+ }
271271+272272+ db.prepare(`DELETE FROM ${tableName} WHERE id = ?`).run(id);
273273+};
274274+275275+export { tableNames };
+224
app/datastore/schema-sql.js
···11+// SQL Schema definitions for direct SQLite
22+// Converted from TinyBase schema.js
33+44+export const createTableStatements = `
55+ -- Addresses: URLs with metadata
66+ CREATE TABLE IF NOT EXISTS addresses (
77+ id TEXT PRIMARY KEY,
88+ uri TEXT NOT NULL,
99+ protocol TEXT DEFAULT 'https',
1010+ domain TEXT,
1111+ path TEXT DEFAULT '',
1212+ title TEXT DEFAULT '',
1313+ mimeType TEXT DEFAULT 'text/html',
1414+ favicon TEXT DEFAULT '',
1515+ description TEXT DEFAULT '',
1616+ tags TEXT DEFAULT '',
1717+ metadata TEXT DEFAULT '{}',
1818+ createdAt INTEGER,
1919+ updatedAt INTEGER,
2020+ lastVisitAt INTEGER DEFAULT 0,
2121+ visitCount INTEGER DEFAULT 0,
2222+ starred INTEGER DEFAULT 0,
2323+ archived INTEGER DEFAULT 0
2424+ );
2525+2626+ CREATE INDEX IF NOT EXISTS idx_addresses_uri ON addresses(uri);
2727+ CREATE INDEX IF NOT EXISTS idx_addresses_domain ON addresses(domain);
2828+ CREATE INDEX IF NOT EXISTS idx_addresses_protocol ON addresses(protocol);
2929+ CREATE INDEX IF NOT EXISTS idx_addresses_lastVisitAt ON addresses(lastVisitAt);
3030+ CREATE INDEX IF NOT EXISTS idx_addresses_visitCount ON addresses(visitCount);
3131+ CREATE INDEX IF NOT EXISTS idx_addresses_starred ON addresses(starred);
3232+3333+ -- Visits: Navigation history linked to addresses
3434+ CREATE TABLE IF NOT EXISTS visits (
3535+ id TEXT PRIMARY KEY,
3636+ addressId TEXT,
3737+ timestamp INTEGER,
3838+ duration INTEGER DEFAULT 0,
3939+ source TEXT DEFAULT 'direct',
4040+ sourceId TEXT DEFAULT '',
4141+ windowType TEXT DEFAULT 'main',
4242+ metadata TEXT DEFAULT '{}',
4343+ scrollDepth INTEGER DEFAULT 0,
4444+ interacted INTEGER DEFAULT 0
4545+ );
4646+4747+ CREATE INDEX IF NOT EXISTS idx_visits_addressId ON visits(addressId);
4848+ CREATE INDEX IF NOT EXISTS idx_visits_timestamp ON visits(timestamp);
4949+ CREATE INDEX IF NOT EXISTS idx_visits_source ON visits(source);
5050+5151+ -- Content: User-created content (notes, etc.)
5252+ CREATE TABLE IF NOT EXISTS content (
5353+ id TEXT PRIMARY KEY,
5454+ title TEXT DEFAULT 'Untitled',
5555+ content TEXT DEFAULT '',
5656+ mimeType TEXT DEFAULT 'text/plain',
5757+ contentType TEXT DEFAULT 'plain',
5858+ language TEXT DEFAULT '',
5959+ encoding TEXT DEFAULT 'utf-8',
6060+ tags TEXT DEFAULT '',
6161+ addressRefs TEXT DEFAULT '',
6262+ parentId TEXT DEFAULT '',
6363+ metadata TEXT DEFAULT '{}',
6464+ createdAt INTEGER,
6565+ updatedAt INTEGER,
6666+ syncPath TEXT DEFAULT '',
6767+ synced INTEGER DEFAULT 0,
6868+ starred INTEGER DEFAULT 0,
6969+ archived INTEGER DEFAULT 0
7070+ );
7171+7272+ CREATE INDEX IF NOT EXISTS idx_content_contentType ON content(contentType);
7373+ CREATE INDEX IF NOT EXISTS idx_content_mimeType ON content(mimeType);
7474+ CREATE INDEX IF NOT EXISTS idx_content_synced ON content(synced);
7575+ CREATE INDEX IF NOT EXISTS idx_content_updatedAt ON content(updatedAt);
7676+7777+ -- Tags: Tag definitions with frecency tracking
7878+ CREATE TABLE IF NOT EXISTS tags (
7979+ id TEXT PRIMARY KEY,
8080+ name TEXT NOT NULL,
8181+ slug TEXT,
8282+ color TEXT DEFAULT '#999999',
8383+ parentId TEXT DEFAULT '',
8484+ description TEXT DEFAULT '',
8585+ metadata TEXT DEFAULT '{}',
8686+ createdAt INTEGER,
8787+ updatedAt INTEGER,
8888+ frequency INTEGER DEFAULT 0,
8989+ lastUsedAt INTEGER DEFAULT 0,
9090+ frecencyScore INTEGER DEFAULT 0
9191+ );
9292+9393+ CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
9494+ CREATE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug);
9595+ CREATE INDEX IF NOT EXISTS idx_tags_parentId ON tags(parentId);
9696+ CREATE INDEX IF NOT EXISTS idx_tags_frecencyScore ON tags(frecencyScore);
9797+9898+ -- Address-Tag join table
9999+ CREATE TABLE IF NOT EXISTS address_tags (
100100+ id TEXT PRIMARY KEY,
101101+ addressId TEXT NOT NULL,
102102+ tagId TEXT NOT NULL,
103103+ createdAt INTEGER
104104+ );
105105+106106+ CREATE INDEX IF NOT EXISTS idx_address_tags_addressId ON address_tags(addressId);
107107+ CREATE INDEX IF NOT EXISTS idx_address_tags_tagId ON address_tags(tagId);
108108+ CREATE UNIQUE INDEX IF NOT EXISTS idx_address_tags_unique ON address_tags(addressId, tagId);
109109+110110+ -- Blobs: Binary files/media
111111+ CREATE TABLE IF NOT EXISTS blobs (
112112+ id TEXT PRIMARY KEY,
113113+ filename TEXT,
114114+ mimeType TEXT,
115115+ mediaType TEXT,
116116+ size INTEGER,
117117+ hash TEXT,
118118+ extension TEXT,
119119+ path TEXT,
120120+ addressId TEXT DEFAULT '',
121121+ contentId TEXT DEFAULT '',
122122+ tags TEXT DEFAULT '',
123123+ metadata TEXT DEFAULT '{}',
124124+ createdAt INTEGER,
125125+ width INTEGER DEFAULT 0,
126126+ height INTEGER DEFAULT 0,
127127+ duration INTEGER DEFAULT 0,
128128+ thumbnail TEXT DEFAULT ''
129129+ );
130130+131131+ CREATE INDEX IF NOT EXISTS idx_blobs_mediaType ON blobs(mediaType);
132132+ CREATE INDEX IF NOT EXISTS idx_blobs_mimeType ON blobs(mimeType);
133133+ CREATE INDEX IF NOT EXISTS idx_blobs_addressId ON blobs(addressId);
134134+ CREATE INDEX IF NOT EXISTS idx_blobs_contentId ON blobs(contentId);
135135+136136+ -- Scripts data: Script execution results
137137+ CREATE TABLE IF NOT EXISTS scripts_data (
138138+ id TEXT PRIMARY KEY,
139139+ scriptId TEXT,
140140+ scriptName TEXT,
141141+ addressId TEXT,
142142+ selector TEXT,
143143+ content TEXT,
144144+ contentType TEXT DEFAULT 'text',
145145+ metadata TEXT DEFAULT '{}',
146146+ extractedAt INTEGER,
147147+ previousValue TEXT DEFAULT '',
148148+ changed INTEGER DEFAULT 0
149149+ );
150150+151151+ CREATE INDEX IF NOT EXISTS idx_scripts_data_scriptId ON scripts_data(scriptId);
152152+ CREATE INDEX IF NOT EXISTS idx_scripts_data_addressId ON scripts_data(addressId);
153153+ CREATE INDEX IF NOT EXISTS idx_scripts_data_changed ON scripts_data(changed);
154154+155155+ -- Feeds: Feed definitions
156156+ CREATE TABLE IF NOT EXISTS feeds (
157157+ id TEXT PRIMARY KEY,
158158+ name TEXT,
159159+ description TEXT DEFAULT '',
160160+ type TEXT,
161161+ query TEXT DEFAULT '',
162162+ schedule TEXT DEFAULT '',
163163+ source TEXT DEFAULT 'internal',
164164+ tags TEXT DEFAULT '',
165165+ metadata TEXT DEFAULT '{}',
166166+ createdAt INTEGER,
167167+ updatedAt INTEGER,
168168+ lastFetchedAt INTEGER DEFAULT 0,
169169+ enabled INTEGER DEFAULT 1
170170+ );
171171+172172+ CREATE INDEX IF NOT EXISTS idx_feeds_type ON feeds(type);
173173+ CREATE INDEX IF NOT EXISTS idx_feeds_enabled ON feeds(enabled);
174174+175175+ -- Extensions: Extension registry
176176+ CREATE TABLE IF NOT EXISTS extensions (
177177+ id TEXT PRIMARY KEY,
178178+ name TEXT,
179179+ description TEXT DEFAULT '',
180180+ version TEXT DEFAULT '1.0.0',
181181+ path TEXT,
182182+ backgroundUrl TEXT DEFAULT '',
183183+ settingsUrl TEXT DEFAULT '',
184184+ iconPath TEXT DEFAULT '',
185185+ builtin INTEGER DEFAULT 0,
186186+ enabled INTEGER DEFAULT 1,
187187+ status TEXT DEFAULT 'installed',
188188+ installedAt INTEGER,
189189+ updatedAt INTEGER,
190190+ lastErrorAt INTEGER DEFAULT 0,
191191+ lastError TEXT DEFAULT '',
192192+ metadata TEXT DEFAULT '{}'
193193+ );
194194+195195+ CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(enabled);
196196+ CREATE INDEX IF NOT EXISTS idx_extensions_status ON extensions(status);
197197+ CREATE INDEX IF NOT EXISTS idx_extensions_builtin ON extensions(builtin);
198198+199199+ -- Extension settings: Key-value storage for extension preferences
200200+ CREATE TABLE IF NOT EXISTS extension_settings (
201201+ id TEXT PRIMARY KEY,
202202+ extensionId TEXT NOT NULL,
203203+ key TEXT NOT NULL,
204204+ value TEXT,
205205+ updatedAt INTEGER
206206+ );
207207+208208+ CREATE INDEX IF NOT EXISTS idx_extension_settings_extensionId ON extension_settings(extensionId);
209209+ CREATE UNIQUE INDEX IF NOT EXISTS idx_extension_settings_unique ON extension_settings(extensionId, key);
210210+`;
211211+212212+// List of all tables for validation and iteration
213213+export const tableNames = [
214214+ 'addresses',
215215+ 'visits',
216216+ 'content',
217217+ 'tags',
218218+ 'address_tags',
219219+ 'blobs',
220220+ 'scripts_data',
221221+ 'feeds',
222222+ 'extensions',
223223+ 'extension_settings'
224224+];