Index › 9. Database App Modules
See how to implement type safety for your databases by creating and using database app modules.
Topics covered
- Kitten’s design philosophy of beautiful defaults and layered complexity.
- Persisting custom objects (instances of your custom classes).
- App Modules.
- Database App Modules.
- How Kitten handles Node module dependencies (automatic updates using
npm ci
, etc.) - Database compaction.
💡 This is an advanced feature. Please feel free to skip this section and return to it later if you find it confusing.
You are not limited to using the default, untyped database Kitten sets up for you.
The default database, like many of the beautiful defaults in Kitten, exists to make it easy to get started with Kitten and use it to build quick Small Web places, teaching programming, etc.
If, however, you’re designing a Small Web place that you will need to maintain for a long period of time and/or that you are going to collaborate with others on, it would make sense to create your own database and to make it type safe so you get both errors and code completion during development time.
You can create your own custom database using a special type of app module called the database app module.
💡 Learn more about App Modules.
Like any other app module, your database app module goes in the special app_modules directory in the root of your project. What’s special is that the app module must be called database.
Start a new project and create your database app module folder:
mkdir -p app_modules/database
Since app modules are local node modules, they must all contain a package.json file.
Create a basic package.json shell in the app_modules/database/ folder:
package.json
{
"name": "@app/database",
"type": "module",
"main": "database.js",
}
Next, install Kitten’s type safety library and JavaScript Database (JSDB) as dependencies:
npm install @small-web/kitten @small-tech/jsdb
💡 JSDB is the database Kitten uses for databases and Kitten expects your database app modules to use it also. No other databases are supported.
Kitten automatically installs the dependencies of app modules and checks for updated dependencies based on the package-lock.json file and installs them if necessary. As long as you make proper use of the package-lock.json file in your Small Web projects, Kitten should keep them updated for you during both development and production.
🪤 The version of JSDB in all of your package-lock.json files must match the version of JSDB in your database app module’s package.json file.
That means both the package-lock.json file of your database app module (which shouldn’t be an issue as it will be updated when you npm install JSDB) and your main package-lock.json file for the project must include the version of JSDB listed in the package.json file of your database app module.
This is easy to forget when upgrading your JSDB version as you must manually run
npm install
from the root folder of your project to ensure that the main project picks up the latest version in its package-lock.json file. Otherwise, Kitten’s attempt to runnpm ci
will fail with the followingEUSAGE
error:
npm ERR! `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
Next, let’s create the database.js file that we’ve denoted as the main
entry point into our module:
database.js
//@ts-check
import path from 'node:path'
import kitten from '@small-web/kitten'
import JSDB from '@small-tech/jsdb'
export class Kitten {
constructor ({ name = '', age = 0 } = {}) {
this.name = name
this.age = age
}
toString () {
return `${this.name} (${this.age} year${this.age === 1 ? '' : 's'} old)`
}
}
/**
@typedef {object} DatabaseSchema
@property {Database} database
@property {Array<Kitten>} kittens
*/
class Database {
initialised = false
constructor (parameters) {
Object.assign(this, parameters)
}
}
// When the database is being opened by the db commands, we don’t compact it.
const compactOnLoad = process.env.jsdbCompactOnLoad === 'false' ? false : true
export const db = /** @type {DatabaseSchema} */ (
JSDB.open(
path.join(kitten.paths.APP_DATA_DIRECTORY, 'db'),
{
compactOnLoad,
classes: [
Kitten,
Database
]
}
)
)
export async function initialise () {
if (db.database === undefined) {
db.database = new Database()
}
// It’s good practice to create each
// property separately as this allows your
// database schema to change more easily
// in the future.
if (db.kittens === undefined) {
db.kittens = []
}
if (!db.database.initialised) {
db.kittens = [
new Kitten({name: 'Fluffy', age: 1}),
new Kitten({name: 'Ms. Meow', age: 3}),
new Kitten({name: 'Whiskers', age: 7})
]
db.database.initialised = true
console.info(`\n • Database initialised.`)
}
return db
}
export default db
The most important thing to note is the async
initialise
function that we’re exporting from the module.
Kitten will import and run this before starting the server when a database app module is found in your project. Furthermore, it will take the database reference returned from this function as set it as the default database (globalThis.kitten.db
or, simply, kitten.db
).
💡 Even though we are not making use of it here, the
initialise
method can take a parameter object. This object has aninternalClasses
object that contains a reference to useful internal classes. Currently, there is only one class listed,Upload
, that you should include in your list ofclasses
when opening the JSDB database if your app stores instances of theUpload
class passed to your routes in therequest.uploads
array.So your initialise method is actually called like this by Kitten:
initialise({ internalClasses: { Upload } })
Notice a few other things:
We are using @ts-check
to have our editor use the TypeScript language server to check for type errors and everything, including the database schema, is strongly typed.
This makes authoring easier as you will get type completions as well as errors when working with database objects.
💡 You can also create a
jsconfig.json
file in your project so you don’t have to keep adding the@ts-check
comment to every file. e.g.,
{ "compilerOptions": { "lib": ["es2023"], "module": "node16", "target": "es2022", "esModuleInterop": true, "moduleResolution": "node16", "skipLibCheck": true, "checkJs": true } }
Also, note that we set JSDB’s compactOnLoad
option based on the value of the process.env.jsdbCompactOnLoad
environment variable set by Kitten. This is to enable Kitten’s database commands to run without compacting (and thereby altering) your database tables while your app might be running (e.g., when you’re debugging your app by running the kitten db tail
command in a separate Terminal window/pane.)
Finally, note that we are storing instances of the Kitten
class in the database and, when opening the JSDB database, we are specifying the Kitten
class in the classes
property of the options object that we pass as the second argument.
💡 Pay particular attention to the custom classes’
constructor
methods. There are two rules your custom classes must adhere to to work with JSDB:
The constructor must accept a parameter object and assign all properties on it to itself. JSDB calls the constructor with this object when recreating instances of persisted custom objects.
You can either do this manually, as we are doing in the
Kitten
class:
constructor ({ name = '', age = 0 } = {}) { this.name = name this.age = age }
Or, you can do it using
Object.assign()
as we are doing in theDatabase
class:constructor (parameters) { Object.assign(this, parameters) }
In the first example, if you’re confused by the constructor’s method signature, it allows us to provide default values for the parameter object’s properties and a default, empty object, as the parameter object’s default. If the constructor is called without an argument, everything will still work.
There’s actually a third way of doing things gives you the advantages of both as is a very useful idiom in Kitten. In this third way, you specify defaults for your properties using instance fields with initialisers and use
Object.assign()
in your constructor to handle updates from JSDB. We could rewrite the Kitten class like this using this idiom:
export class Kitten { name = '' age = 0 constructor (parameters = {}) { Object.assign(this, parameters) } //… }
This has the advantage of having inferred type information for type checking (like the first method), while keeping you from repeating yourself (like the second method) by manually having to copy the properties of the parameters object into your model instance.
If you need to access the database itself from inside your model, you cannot do so in the constructor or in regular instance properties (because the database will not have been initialised yet when your object is being initialised as part of the database initialisation). Instead, you can do so in regular methods and, if you want to assign values from the database to properties of your class, you can use accessors (
get
andset
methods) to do so as these are not evaluated at instance creation but lazily when called during the execution of your app (by which time the database will, of course, have been initialised).Other than that, you can do anything you can do with a regular class, like extend a base class like
EventEmitter
, for example.
Next, in order for our types to be recognised when the module is imported, we need to add a very simple TypeScript type definition file:
index.d.ts
// Export database instance as default export.
import db from './database.js'
export default db
// Also export all other exports.
export * from './database.js'
The database is ready so now comes the fun part: using it.
First, let’s actuall install the database app module as a dependency of our project.
From the main project folder, run:
npm init -y
This will create an empty default package.json file for your project.
Next, run:
npm install ./app_modules/database
That will install the database app module as a dependency, so you can refer to it as @app/database
in your imports, and add an entry in your project’s package.json file that looks like this:
"dependencies": {
"@app/database": "file:app_modules/database",
…
}
Finally, let’s add a page that uses our typed database to display the kittens in the Kitten database, along with their ages:
index.page.js
// @ts-check
import db from '@app/database'
export default () => _.html`
<h1>Kittens</h1>
<ul>
${db.kittens.map(kitten => _.html`
<li>${kitten}</li>
`)}
</ul>
`
Using database app modules your database, like the rest of your project, can be strongly typed.
View the type of the kitten
variable in the html template, for example, to confirm that it is, indeed, a Kitten
instance.
Also note that because the kittens are instances of the Kitten
class, we can simply refer to them in the template and their description will be printed out for us as their overriden toString()
methods are called behind the scenes.
Phew! So that was a lot. But I hope you now have some idea of some of the cool things you can achieve using JSDB in your Small Web apps.
Now, let’s take a look at how you can work with remote APIs and, as we foray into dealing with untrusted data, at how Kitten automatically handles sanitisation and what options you have for either overriding or manually triggering sanitisation.
Next tutorial: Fetching and working with data