Kitten

See how to implement type safety for your databases by creating and using database app modules.

Topics covered

💡 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 run npm ci will fail with the following EUSAGE 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 an internalClasses object that contains a reference to useful internal classes. Currently, there is only one class listed, Upload, that you should include in your list of classes when opening the JSDB database if your app stores instances of the Upload class passed to your routes in the request.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:

  1. 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 the Database 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.

  2. 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 and set 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

Like this? Fund us!

Small Technology Foundation is a tiny, independent not-for-profit.

We exist in part thanks to patronage by people like you. If you share our vision and want to support our work, please become a patron or donate to us today and help us continue to exist.