diff --git a/Pragmatic/static/application.js b/Pragmatic/static/application.js index a042b25..c5e0372 100644 --- a/Pragmatic/static/application.js +++ b/Pragmatic/static/application.js @@ -22,8 +22,7 @@ const logger = new Logger('output'); const schemas = { user: { keyPath: 'id', autoIncrement: true }, }; -const db = await new Database('Example', 1, schemas); -const repo = db.getStore('user'); +const db = await new Database('Example', { version: 1, schemas }); const actions = { add: async () => { @@ -32,20 +31,20 @@ const actions = { const age = parseInt(prompt('Enter age:'), 10); if (!Number.isInteger(age)) return; const user = { name, age }; - await repo.insert(user); + await db.insert('user', user); logger.log('Added:', user); }, get: async () => { - const users = await repo.select(); + const users = await db.select('user'); logger.log('Users:', users); }, update: async () => { - const user = await repo.get({ id: 1 }); + const user = await db.get('user', { id: 1 }); if (user) { user.age += 1; - await repo.update(user); + await db.update('user', user); logger.log('Updated:', user); } else { logger.log('User with id=1 not found'); @@ -53,13 +52,13 @@ const actions = { }, delete: async () => { - await repo.delete({ id: 2 }); + await db.delete('user', { id: 2 }); logger.log('Deleted user with id=2'); }, adults: async () => { - const users = await repo.select({ - where: (user) => user.age >= 18, + const users = await db.select('user', { + filter: (user) => user.age >= 18, order: 'name asc', limit: 10, }); diff --git a/Pragmatic/static/storage.js b/Pragmatic/static/storage.js index 9f513c0..be5f884 100644 --- a/Pragmatic/static/storage.js +++ b/Pragmatic/static/storage.js @@ -1,135 +1,55 @@ -class Repository { - constructor(store, db, schema) { - this.store = store; - this.db = db; - this.schema = schema; - } - - insert(record) { - const op = (store) => store.add(record); - return this.db.execute(this.store, 'readwrite', op); - } - - async select({ where, limit, offset, order } = {}) { - const op = (store) => { - const results = []; - let skipped = 0; - return new Promise((resolve, reject) => { - const cursor = store.openCursor(); - const done = () => resolve(Repository.#order(results, order)); - cursor.onerror = () => reject(cursor.error); - cursor.onsuccess = (event) => { - const cursor = event.target.result; - if (!cursor) return void done(); - const record = cursor.value; - if (!where || where(record)) { - if (!offset || skipped >= offset) { - results.push(record); - if (limit && results.length >= limit) return void done(); - } else { - skipped++; - } - } - cursor.continue(); - }; - }); - }; - return this.db.execute(this.store, 'readonly', op); - } - - static #order(arr, order) { - if (!order) return arr; - const [field, dir = 'asc'] = order.split(' '); - const sign = dir === 'desc' ? -1 : 1; - return [...arr].sort((a, b) => { - if (a[field] === b[field]) return 0; - return a[field] > b[field] ? sign : -sign; - }); - } - - get({ id }) { - return this.db.execute(this.store, 'readonly', (store) => { - const req = store.get(id); - return new Promise((resolve, reject) => { - req.onerror = () => reject(req.error || new Error(`Can't get ${id}`)); - req.onsuccess = () => resolve(req.result); - }); - }); - } - - update(record) { - const op = (store) => store.put(record); - return this.db.execute(this.store, 'readwrite', op); - } - - delete({ id }) { - const op = (store) => store.delete(id); - return this.db.execute(this.store, 'readwrite', op); - } -} - class Database { #name; - #version; - #schemas; - #instance; + #version = 1; + #schemas = null; + #instance = null; #active = false; - #stores = new Map(); - constructor(name, version, schemas) { + constructor(name, { version, schemas }) { this.#name = name; - this.#version = version; - this.#schemas = schemas; + if (version) this.#version = version; + if (schemas) this.#schemas = schemas; return this.#open(); } async #open() { - await this.#connect(); - await this.#init(); - return this; - } - - async #connect() { - this.#instance = await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const request = indexedDB.open(this.#name, this.#version); - request.onupgradeneeded = (event) => this.#upgrade(event); + request.onupgradeneeded = (event) => this.#upgrade(event.target.result); request.onsuccess = (event) => { this.#active = true; - resolve(event.target.result); + this.#instance = event.target.result; + resolve(); }; request.onerror = (event) => { - reject(event.target.error || new Error('IndexedDB open error')); + let { error } = event.target; + if (!error) error = new Error(`IndexedDB: can't open ${this.#name}`); + reject(error); }; }); + return this; } - #init() { - for (const [name, schema] of Object.entries(this.#schemas)) { - const store = new Repository(name, this, schema); - this.#stores.set(name, store); - } - } - - #upgrade(event) { - const db = event.target.result; + #upgrade(db) { for (const [name, schema] of Object.entries(this.#schemas)) { if (!db.objectStoreNames.contains(name)) { - db.createObjectStore(name, schema); + const store = db.createObjectStore(name, schema); + const indexes = schema.indexes || []; + for (const index of Object.entries(indexes)) { + store.createIndex(index.name, index.keyPath, index.options); + } } } } - getStore(name) { - return this.#stores.get(name); - } - - async execute(storeName, mode, operation) { - if (!this.#active) throw new Error('Database not connected'); - const db = this.#instance; + #exec(entity, operation, mode = 'readwrite') { + if (!this.#active) { + return Promise.reject(new Error('Database not connected')); + } return new Promise((resolve, reject) => { try { - const tx = db.transaction(storeName, mode); - const store = tx.objectStore(storeName); + const tx = this.#instance.transaction(entity, mode); + const store = tx.objectStore(entity); const result = operation(store); tx.oncomplete = () => resolve(result); tx.onerror = () => reject(tx.error || new Error('Transaction error')); @@ -138,6 +58,82 @@ class Database { } }); } + + async insert(entity, record) { + return this.#exec(entity, (store) => store.add(record)); + } + + async update(entity, record) { + return this.#exec(entity, (store) => store.put(record)); + } + + async delete(entity, { id }) { + return this.#exec(entity, (store) => store.delete(id)); + } + + async get(entity, { id }) { + return this.#exec( + entity, + (store) => { + const req = store.get(id); + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error || new Error(`Can't get ${id}`)); + }); + }, + 'readonly', + ); + } + + async select(entity, { where, limit, offset, order, filter, sort } = {}) { + return this.#exec( + entity, + (store) => { + const results = []; + let skipped = 0; + return new Promise((resolve, reject) => { + const req = store.openCursor(); + req.onerror = () => reject(req.error); + req.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + const filtered = filter ? results.filter(filter) : results; + const sorted = sort + ? filtered.toSorted(sort) + : Database.#order(filtered, order); + return void resolve(sorted); + } + const record = cursor.value; + const match = + !where || + Object.entries(where).every(([key, val]) => record[key] === val); + if (match) { + if (!offset || skipped >= offset) { + results.push(record); + if (limit && results.length >= limit) { + return void resolve(results); + } + } else { + skipped++; + } + } + cursor.continue(); + }; + }); + }, + 'readonly', + ); + } + + static #order(arr, order) { + if (!order || typeof order !== 'object') return arr; + const [[field, dir]] = Object.entries(order); + const sign = dir === 'desc' ? -1 : 1; + return [...arr].sort((a, b) => { + if (a[field] === b[field]) return 0; + return a[field] > b[field] ? sign : -sign; + }); + } } -export { Repository, Database }; +export { Database }; diff --git a/Pragmatic/test/database.js b/Pragmatic/test/database.js index 00ad280..3abe1fd 100644 --- a/Pragmatic/test/database.js +++ b/Pragmatic/test/database.js @@ -3,11 +3,28 @@ import assert from 'node:assert/strict'; import 'fake-indexeddb/auto'; import { Database } from '../static/storage.js'; -test('Database connects and exposes repository', async () => { - const entities = { +test('Database basic CRUD', async () => { + const schemas = { user: { keyPath: 'id', autoIncrement: true }, }; - const db = await new Database('TestDatabase', 1, entities); - const user = db.getStore('user'); - assert.ok(user); + const db = await new Database('TestDatabase', { version: 1, schemas }); + + await db.insert('user', { name: 'Marcus', age: 30 }); + await db.insert('user', { name: 'Lucius', age: 20 }); + + const users = await db.select('user'); + assert.equal(users.length, 2); + + const record = await db.get('user', { id: 1 }); + assert.equal(record.name, 'Marcus'); + + record.age++; + await db.update('user', record); + + const updated = await db.get('user', { id: 1 }); + assert.equal(updated.age, 31); + + await db.delete('user', { id: 2 }); + const remaining = await db.select('user'); + assert.equal(remaining.length, 1); }); diff --git a/Pragmatic/test/repository.js b/Pragmatic/test/repository.js deleted file mode 100644 index 18b7ff5..0000000 --- a/Pragmatic/test/repository.js +++ /dev/null @@ -1,31 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import 'fake-indexeddb/auto'; -import { Database } from '../static/storage.js'; - -test('Repository performs basic CRUD', async () => { - const schemas = { - user: { keyPath: 'id', autoIncrement: true }, - }; - const db = await new Database('TestDatabase', 1, schemas); - const user = db.getStore('user'); - - await user.insert({ name: 'Alice', age: 30 }); - await user.insert({ name: 'Bob', age: 20 }); - - const users = await user.select(); - assert.equal(users.length, 2); - - const record = await user.get({ id: 1 }); - assert.equal(record.name, 'Alice'); - - record.age++; - await user.update(record); - - const updated = await user.get({ id: 1 }); - assert.equal(updated.age, 31); - - await user.delete({ id: 2 }); - const remaining = await user.select(); - assert.equal(remaining.length, 1); -});