diff --git a/src/app.ts b/src/app.ts index 149bfc0..3b915fd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,10 +3,9 @@ import os from 'os'; import path from 'path'; import { Command, OptionValues } from 'commander'; import fs from 'fs-extra'; -import initSqlJs from 'sql.js'; import { DiskBasedPackageCache } from './cache'; import { BuildDotFhirDotOrgClient } from './current'; -import { SQLJSPackageDB } from './db'; +import { newSQLJSPackageDB } from './db'; import { BasePackageLoader } from './loader'; import { DefaultRegistryClient } from './registry'; import { logger } from './utils'; @@ -33,8 +32,7 @@ async function install(fhirPackages: string[], options: OptionValues) { logger.log(level, message); }; - const SQL = await initSqlJs(); - const packageDB = new SQLJSPackageDB(new SQL.Database()); + const packageDB = await newSQLJSPackageDB(); const fhirCache = options.cachePath ?? path.join(os.homedir(), '.fhir', 'packages'); const packageCache = new DiskBasedPackageCache(fhirCache, { log }); const registryClient = new DefaultRegistryClient({ log }); diff --git a/src/db/SQLJSPackageDB.ts b/src/db/SQLJSPackageDB.ts index 21c502f..96a3d99 100644 --- a/src/db/SQLJSPackageDB.ts +++ b/src/db/SQLJSPackageDB.ts @@ -1,5 +1,4 @@ -import util from 'util'; -import { Database, Statement } from 'sql.js'; +import initSqlJs, { Database, Statement } from 'sql.js'; import { FindResourceInfoOptions, PackageInfo, PackageStats, ResourceInfo } from '../package'; import { PackageDB } from './PackageDB'; @@ -105,50 +104,74 @@ const INSERT_RESOURCE = `INSERT INTO resource const SD_FLAVORS = ['Extension', 'Logical', 'Profile', 'Resource', 'Type']; export class SQLJSPackageDB implements PackageDB { + private db: Database; private insertPackageStmt: Statement; private insertResourceStmt: Statement; private findAllPackagesStmt: Statement; private findPackagesStmt: Statement; private findPackageStmt: Statement; private optimized: boolean; - constructor( - private db: Database, - initialize = true - ) { - if (initialize) { - this.db.run( - [ - CREATE_PACKAGE_TABLE, - CREATE_PACKAGE_TABLE_INDICES, - CREATE_RESOURCE_TABLE, - CREATE_RESOURCE_TABLE_INDICES - ].join(';') - ); + private initialized: boolean; + + constructor() { + this.initialized = false; + } + + async initialize() { + if (!this.initialized) { + const SQL = await initSqlJs(); + // check initialization state once more since initSqlJs call was async (possible race condition) + if (!this.initialized) { + this.db = new SQL.Database(); + this.db.run( + [ + CREATE_PACKAGE_TABLE, + CREATE_PACKAGE_TABLE_INDICES, + CREATE_RESOURCE_TABLE, + CREATE_RESOURCE_TABLE_INDICES + ].join(';') + ); + this.insertPackageStmt = this.db.prepare(INSERT_PACKAGE); + this.insertResourceStmt = this.db.prepare(INSERT_RESOURCE); + this.findAllPackagesStmt = this.db.prepare(FIND_ALL_PACKAGES); + this.findPackagesStmt = this.db.prepare(FIND_PACKAGES); + this.findPackageStmt = this.db.prepare(FIND_PACKAGE); + this.initialized = true; + this.optimized = false; + } } - this.insertPackageStmt = this.db.prepare(INSERT_PACKAGE); - this.insertResourceStmt = this.db.prepare(INSERT_RESOURCE); - this.findAllPackagesStmt = this.db.prepare(FIND_ALL_PACKAGES); - this.findPackagesStmt = this.db.prepare(FIND_PACKAGES); - this.findPackageStmt = this.db.prepare(FIND_PACKAGE); - this.optimized = false; + } + + isInitialized() { + return this.initialized; } clear() { - this.db.exec('DELETE FROM package'); - this.db.exec('DELETE FROM resource'); - this.db.exec('VACUUM'); + if (this.db) { + this.db.exec('DELETE FROM package'); + this.db.exec('DELETE FROM resource'); + this.db.exec('VACUUM'); + this.optimized = false; + } } optimize() { - if (!this.optimized) { - this.db.exec('PRAGMA optimize=0x10002'); - this.optimized = true; - } else { - this.db.exec('PRAGMA optimize'); + if (this.db) { + if (!this.optimized) { + this.db.exec('PRAGMA optimize=0x10002'); + this.optimized = true; + } else { + this.db.exec('PRAGMA optimize'); + } } } savePackageInfo(info: PackageInfo): void { + if (!this.db) { + throw new Error( + 'SQLJSPackageDB not initialized. Please call the initialize() function before using this class.' + ); + } const binding: any = { ':name': info.name, ':version': info.version @@ -163,6 +186,11 @@ export class SQLJSPackageDB implements PackageDB { } saveResourceInfo(info: ResourceInfo): void { + if (!this.db) { + throw new Error( + 'SQLJSPackageDB not initialized. Please call the initialize() function before using this class.' + ); + } const binding: any = { ':resourceType': info.resourceType }; @@ -221,6 +249,11 @@ export class SQLJSPackageDB implements PackageDB { } findPackageInfos(name: string): PackageInfo[] { + if (!this.db) { + throw new Error( + 'SQLJSPackageDB not initialized. Please call the initialize() function before using this class.' + ); + } const results: PackageInfo[] = []; const findStmt = name === '*' ? this.findAllPackagesStmt : this.findPackagesStmt; try { @@ -237,6 +270,11 @@ export class SQLJSPackageDB implements PackageDB { } findPackageInfo(name: string, version: string): PackageInfo | undefined { + if (!this.db) { + throw new Error( + 'SQLJSPackageDB not initialized. Please call the initialize() function before using this class.' + ); + } try { this.findPackageStmt.bind({ ':name': name, ':version': version }); if (this.findPackageStmt.step()) { @@ -248,6 +286,11 @@ export class SQLJSPackageDB implements PackageDB { } findResourceInfos(key: string, options: FindResourceInfoOptions = {}): ResourceInfo[] { + if (!this.db) { + throw new Error( + 'SQLJSPackageDB not initialized. Please call the initialize() function before using this class.' + ); + } // In case a key wasn't supplied, just use empty string. Later we might have it return ALL. if (key == null) { key = ''; @@ -355,6 +398,11 @@ export class SQLJSPackageDB implements PackageDB { } findResourceInfo(key: string, options: FindResourceInfoOptions = {}): ResourceInfo | undefined { + if (!this.db) { + throw new Error( + 'SQLJSPackageDB not initialized. Please call the initialize() function before using this class.' + ); + } // TODO: Make this more sophisticated if/when it makes sense const results = this.findResourceInfos(key, { ...options, limit: 1 }); if (results.length > 0) { @@ -363,6 +411,11 @@ export class SQLJSPackageDB implements PackageDB { } getPackageStats(name: string, version: string): PackageStats | undefined { + if (!this.db) { + throw new Error( + 'SQLJSPackageDB not initialized. Please call the initialize() function before using this class.' + ); + } const pkg = this.findPackageInfo(name, version); if (pkg == null) { return; @@ -378,18 +431,21 @@ export class SQLJSPackageDB implements PackageDB { }; } - exportDB(): Promise<{ mimeType: string; data: Buffer }> { + async exportDB(): Promise<{ mimeType: string; data: Buffer }> { + if (!this.db) { + return Promise.reject( + new Error( + 'SQLJSPackageDB not initialized. Please call the initialize() function before using this class.' + ) + ); + } const data = this.db.export(); return Promise.resolve({ mimeType: 'application/x-sqlite3', data: Buffer.from(data) }); } +} - logPackageTable() { - const res = this.db.exec('SELECT * FROM package'); - console.log(util.inspect(res, false, 3, true)); - } - - logResourceTable() { - const res = this.db.exec('SELECT * FROM resource'); - console.log(util.inspect(res, false, 3, true)); - } +export async function newSQLJSPackageDB(): Promise { + const packageDB = new SQLJSPackageDB(); + await packageDB.initialize(); + return packageDB; } diff --git a/src/loader/DefaultPackageLoader.ts b/src/loader/DefaultPackageLoader.ts index 25ffb38..04f87e0 100644 --- a/src/loader/DefaultPackageLoader.ts +++ b/src/loader/DefaultPackageLoader.ts @@ -1,15 +1,13 @@ import os from 'os'; import path from 'path'; -import initSqlJs from 'sql.js'; import { DiskBasedPackageCache } from '../cache/DiskBasedPackageCache'; import { BuildDotFhirDotOrgClient } from '../current'; -import { SQLJSPackageDB } from '../db'; +import { newSQLJSPackageDB } from '../db'; import { DefaultRegistryClient } from '../registry'; import { BasePackageLoader, BasePackageLoaderOptions } from './BasePackageLoader'; export async function defaultPackageLoader(options: BasePackageLoaderOptions) { - const SQL = await initSqlJs(); - const packageDB = new SQLJSPackageDB(new SQL.Database()); + const packageDB = await newSQLJSPackageDB(); const fhirCache = path.join(os.homedir(), '.fhir', 'packages'); const packageCache = new DiskBasedPackageCache(fhirCache, { log: options.log diff --git a/test/db/SQLJSPackageDB.test.ts b/test/db/SQLJSPackageDB.test.ts index de5ad72..b85b343 100644 --- a/test/db/SQLJSPackageDB.test.ts +++ b/test/db/SQLJSPackageDB.test.ts @@ -1,5 +1,5 @@ import initSqlJs from 'sql.js'; -import { SQLJSPackageDB } from '../../src/db/SQLJSPackageDB'; +import { newSQLJSPackageDB, SQLJSPackageDB } from '../../src/db/SQLJSPackageDB'; import { ResourceInfo } from '../../src/package'; import { byLoadOrder, byType } from '../../src/sort'; import { loggerSpy } from '../testhelpers'; @@ -7,7 +7,6 @@ import { loggerSpy } from '../testhelpers'; describe('SQLJSPackageDB', () => { let SQL: initSqlJs.SqlJsStatic; let sqlDb: initSqlJs.Database; - let dbRunSpy: jest.SpyInstance; beforeAll(async () => { SQL = await initSqlJs(); @@ -16,19 +15,19 @@ describe('SQLJSPackageDB', () => { beforeEach(() => { loggerSpy.reset(); sqlDb = new SQL.Database(); - dbRunSpy = jest.spyOn(sqlDb, 'run'); }); afterEach(() => { - dbRunSpy.mockReset(); sqlDb.close(); }); describe('constructor', () => { - it('should create and initialize a new SQLJSPackageDB', () => { - const packageDb = new SQLJSPackageDB(sqlDb); + it('should create and initialize a new SQLJSPackageDB', async () => { + const packageDb = new SQLJSPackageDB(); + expect(packageDb.isInitialized()).toBe(false); + await packageDb.initialize(); expect(packageDb).toBeDefined(); - expect(dbRunSpy).toHaveBeenCalledTimes(1); + expect(packageDb.isInitialized()).toBe(true); }); }); @@ -60,8 +59,8 @@ describe('SQLJSPackageDB', () => { packageVersion: '4.5.6' }; - beforeEach(() => { - packageDb = new SQLJSPackageDB(sqlDb); + beforeEach(async () => { + packageDb = await newSQLJSPackageDB(); packageDb.savePackageInfo({ name: 'CookiePackage', version: '3.2.2', @@ -77,6 +76,11 @@ describe('SQLJSPackageDB', () => { packageDb.saveResourceInfo(valueSetFour); }); + it('should not throw even if the db is not initialized', () => { + const uninitializedPackageDB = new SQLJSPackageDB(); + uninitializedPackageDB.clear(); + }); + it('should remove all packages and resources', () => { // we start with some packages and resources const beforePackageInfo = packageDb.findPackageInfos('CookiePackage'); @@ -104,8 +108,8 @@ describe('SQLJSPackageDB', () => { packageVersion: '4.5.6' }; - beforeEach(() => { - packageDb = new SQLJSPackageDB(sqlDb); + beforeEach(async () => { + packageDb = await newSQLJSPackageDB(); packageDb.savePackageInfo({ name: 'CookiePackage', version: '4.5.6', @@ -114,6 +118,11 @@ describe('SQLJSPackageDB', () => { packageDb.saveResourceInfo(specialExtension); }); + it('should not throw even if the db is not initialized', () => { + const uninitializedPackageDB = new SQLJSPackageDB(); + uninitializedPackageDB.optimize(); + }); + it('should run optimization without any errors', () => { // there's no good way to see if it actually is optimized, // so just ensure it runs without error and queries still work. @@ -128,8 +137,18 @@ describe('SQLJSPackageDB', () => { describe('#savePackageInfo', () => { let packageDb: SQLJSPackageDB; - beforeEach(() => { - packageDb = new SQLJSPackageDB(sqlDb); + beforeEach(async () => { + packageDb = await newSQLJSPackageDB(); + }); + + it('should throw if the db has not been initialized', () => { + const uninitializedPackageDB = new SQLJSPackageDB(); + expect(() => { + uninitializedPackageDB.savePackageInfo({ + name: 'MyPackage', + version: '1.0.4' + }); + }).toThrow(/SQLJSPackageDB not initialized/); }); it('should save package info with a name and version', () => { @@ -137,7 +156,6 @@ describe('SQLJSPackageDB', () => { name: 'MyPackage', version: '1.0.4' }); - expect(dbRunSpy).toHaveBeenCalledTimes(1); const savedPackage = packageDb.findPackageInfo('MyPackage', '1.0.4'); expect(savedPackage).toEqual( expect.objectContaining({ @@ -153,7 +171,6 @@ describe('SQLJSPackageDB', () => { version: '1.0.4', packagePath: '/var/data/.fhir/MyPackage-1.0.4' }); - expect(dbRunSpy).toHaveBeenCalledTimes(1); const savedPackage = packageDb.findPackageInfo('MyPackage', '1.0.4'); expect(savedPackage).toEqual( expect.objectContaining({ @@ -170,7 +187,6 @@ describe('SQLJSPackageDB', () => { version: '1.0.4', packageJSONPath: '/var/data/.fhir/MyPackage-1.0.4/package.json' }); - expect(dbRunSpy).toHaveBeenCalledTimes(1); const savedPackage = packageDb.findPackageInfo('MyPackage', '1.0.4'); expect(savedPackage).toEqual( expect.objectContaining({ @@ -188,7 +204,6 @@ describe('SQLJSPackageDB', () => { packagePath: '/var/data/.fhir/MyPackage-1.0.4', packageJSONPath: '/var/data/.fhir/MyPackage-1.0.4/package.json' }); - expect(dbRunSpy).toHaveBeenCalledTimes(1); const savedPackage = packageDb.findPackageInfo('MyPackage', '1.0.4'); expect(savedPackage).toEqual( expect.objectContaining({ @@ -204,8 +219,18 @@ describe('SQLJSPackageDB', () => { describe('#saveResourceInfo', () => { let packageDb: SQLJSPackageDB; - beforeEach(() => { - packageDb = new SQLJSPackageDB(sqlDb); + beforeEach(async () => { + packageDb = await newSQLJSPackageDB(); + }); + + it('should throw if the db has not been initialized', () => { + const uninitializedPackageDB = new SQLJSPackageDB(); + expect(() => { + uninitializedPackageDB.saveResourceInfo({ + resourceType: 'StructureDefinition', + id: 'my-patient-profile' + }); + }).toThrow(/SQLJSPackageDB not initialized/); }); it('should save a simple resource', () => { @@ -213,7 +238,6 @@ describe('SQLJSPackageDB', () => { resourceType: 'StructureDefinition', id: 'my-patient-profile' }); - expect(dbRunSpy).toHaveBeenCalledTimes(1); const resource = packageDb.findResourceInfo('my-patient-profile'); expect(resource).toEqual( expect.objectContaining({ @@ -234,7 +258,6 @@ describe('SQLJSPackageDB', () => { packageVersion: '3.2.2', resourcePath: '/var/data/.fhir/RegularPackage-3.2.2/ValueSets/my-value-set.json' }); - expect(dbRunSpy).toHaveBeenCalledTimes(1); const resource = packageDb.findResourceInfo('my-value-set'); expect(resource).toEqual( expect.objectContaining({ @@ -271,7 +294,6 @@ describe('SQLJSPackageDB', () => { packageName: 'RegularPackage', packageVersion: '3.2.2' }); - expect(dbRunSpy).toHaveBeenCalledTimes(1); const resource = packageDb.findResourceInfo('my-patient-profile'); expect(resource).toEqual( expect.objectContaining({ @@ -299,8 +321,8 @@ describe('SQLJSPackageDB', () => { describe('#findPackageInfos', () => { let packageDb: SQLJSPackageDB; - beforeEach(() => { - packageDb = new SQLJSPackageDB(sqlDb); + beforeEach(async () => { + packageDb = await newSQLJSPackageDB(); packageDb.savePackageInfo({ name: 'CookiePackage', version: '1.0.0', @@ -318,6 +340,13 @@ describe('SQLJSPackageDB', () => { }); }); + it('should throw if the db has not been initialized', () => { + const uninitializedPackageDB = new SQLJSPackageDB(); + expect(() => { + uninitializedPackageDB.findPackageInfos('*'); + }).toThrow(/SQLJSPackageDB not initialized/); + }); + it('should return all packages when * is passed in as the name', () => { const results = packageDb.findPackageInfos('*'); expect(results).toHaveLength(3); @@ -372,8 +401,8 @@ describe('SQLJSPackageDB', () => { describe('#findPackageInfo', () => { let packageDb: SQLJSPackageDB; - beforeEach(() => { - packageDb = new SQLJSPackageDB(sqlDb); + beforeEach(async () => { + packageDb = await newSQLJSPackageDB(); packageDb.savePackageInfo({ name: 'CookiePackage', version: '1.0.0', @@ -391,6 +420,13 @@ describe('SQLJSPackageDB', () => { }); }); + it('should throw if the db has not been initialized', () => { + const uninitializedPackageDB = new SQLJSPackageDB(); + expect(() => { + uninitializedPackageDB.findPackageInfo('CookiePackage', '1.0.3'); + }).toThrow(/SQLJSPackageDB not initialized/); + }); + it('should return a package that matches a name and version', () => { const result = packageDb.findPackageInfo('CookiePackage', '1.0.3'); expect(result).toEqual( @@ -464,8 +500,8 @@ describe('SQLJSPackageDB', () => { packageVersion: '4.5.6' }; - beforeEach(() => { - packageDb = new SQLJSPackageDB(sqlDb); + beforeEach(async () => { + packageDb = await newSQLJSPackageDB(); packageDb.saveResourceInfo(patientProfile); packageDb.saveResourceInfo(observationProfile); packageDb.saveResourceInfo(specialExtension); @@ -473,6 +509,13 @@ describe('SQLJSPackageDB', () => { packageDb.saveResourceInfo(valueSetFour); }); + it('should throw if the db has not been initialized', () => { + const uninitializedPackageDB = new SQLJSPackageDB(); + expect(() => { + uninitializedPackageDB.findResourceInfos('*'); + }).toThrow(/SQLJSPackageDB not initialized/); + }); + it('should find all resources when the key is *', () => { const resources = packageDb.findResourceInfos('*'); expect(resources).toHaveLength(5); @@ -785,13 +828,20 @@ describe('SQLJSPackageDB', () => { packageVersion: '4.5.6' }; - beforeEach(() => { - packageDb = new SQLJSPackageDB(sqlDb); + beforeEach(async () => { + packageDb = await newSQLJSPackageDB(); packageDb.saveResourceInfo(specialExtension); packageDb.saveResourceInfo(valueSetThree); packageDb.saveResourceInfo(valueSetFour); }); + it('should throw if the db has not been initialized', () => { + const uninitializedPackageDB = new SQLJSPackageDB(); + expect(() => { + uninitializedPackageDB.findResourceInfo('my-value-set'); + }).toThrow(/SQLJSPackageDB not initialized/); + }); + it('should return one resource when there is at least one match by resource id', () => { const resource = packageDb.findResourceInfo('my-value-set'); expect(resource).toBeDefined(); @@ -944,8 +994,8 @@ describe('SQLJSPackageDB', () => { packageVersion: '4.5.6' }; - beforeEach(() => { - packageDb = new SQLJSPackageDB(sqlDb); + beforeEach(async () => { + packageDb = await newSQLJSPackageDB(); packageDb.savePackageInfo({ name: 'CookiePackage', version: '3.2.2', @@ -966,6 +1016,13 @@ describe('SQLJSPackageDB', () => { packageDb.saveResourceInfo(valueSetFour); }); + it('should throw if the db has not been initialized', () => { + const uninitializedPackageDB = new SQLJSPackageDB(); + expect(() => { + uninitializedPackageDB.getPackageStats('CookiePackage', '4.5.6'); + }).toThrow(/SQLJSPackageDB not initialized/); + }); + it('should return a count of resources for a package', () => { const result = packageDb.getPackageStats('CookiePackage', '4.5.6'); expect(result).toEqual({ @@ -983,8 +1040,8 @@ describe('SQLJSPackageDB', () => { describe('#exportDB', () => { let packageDb: SQLJSPackageDB; - beforeEach(() => { - packageDb = new SQLJSPackageDB(sqlDb); + beforeEach(async () => { + packageDb = await newSQLJSPackageDB(); packageDb.savePackageInfo({ name: 'CookiePackage', version: '3.2.2', @@ -992,6 +1049,13 @@ describe('SQLJSPackageDB', () => { }); }); + it('should throw if the db has not been initialized', async () => { + const uninitializedPackageDB = new SQLJSPackageDB(); + await expect(uninitializedPackageDB.exportDB()).rejects.toThrow( + /SQLJSPackageDB not initialized/ + ); + }); + it('should return an object with the correct mimetype and some data', async () => { const result = await packageDb.exportDB(); expect(result).toBeDefined();