A DSL for defining IndexedDB schemas.
Generate a type-safe IndexedDB client from a simple schema file.
npm i -D indeksd
Define a schema for your database.
database Blog {
table Posts @item("Post") {
@autoincrement id: number;
@index title: string;
content: string;
@index author: string;
}
}
This schema would create a database named "Blog" with a single object store names "Posts". The object store would have an autoincrement primary key "id" and two indexes "title" and "author" to search by.
Codegen can be performed programatically or via the command line.
Via command line:
inkeksd --sourceDir schemas --outDir codegen blog.db
Available options:
- --outDir: Where to save generated files to, relative to current directory. Will be created if it doesn't exist. Defaults to './codegen'.
- --sourceDir: The directory to search for raw schema files.
- --rootDir: Root directory for the project, used to resolve outDir and sourceDir if provided. Defaults to cwd.
Anything passed beyond these will be assumed to be source files. If a given source file can't be found relative to sourceDir
an error will be thrown.
The generated code uses no external dependencies. It just requires an environment with indexedDB
defined on globalThis
.
import {init} from './codegen/blog';
const db = await init();
const postId = await db.posts.add({
title: 'First Post',
content: 'This is the first post.',
author: 'Kevin',
});
Because our "Posts" table was defined with an autoincrement "id" field the input type to the "add" method is defined as Omit<Post, 'id'>
. We do get the postId
as the result of the add
operation.
Then when we want to get, we can use either the primary key, or one of our indexes.
const result1 = await db.posts.get(postId);
const result2 = await db.posts.get({id: postId});
const result3 = await db.posts.get({title: 'First Post'});
const result4 = await db.posts.get({author: 'Kevin'});
Again the argument types here are all generated so we couldn't try to perform a get with a field not defined as an index without getting a type error.
For more clarity the argument type for the get operation of "Posts" object store would be generated as:
export type PostsGetArgs = number | {
id: number;
} | {
title: string;
} | {
author: string;
};
A type is also generated for the objects stored in our object store. By default the type name is the same as the table name. However, if you want to define a different name you can use the @item annotation to define a name. In our example we called the table "Posts", but wanted items in the table to use the singular "Post".
For our example this would be generated as:
export type Post = {
id: number;
title: string;
content: string;
author: string;
};
You can import this type from the generated file.
import {init, Post} from './codegen/blog';
Annotations are used in the schema to further define the database we want.
Supported annotations:
- @version - Defined on the database to supply the version number.
- @item - Used on the table to define what name to use for a single item in a table. For example a "Posts" table could define each item as "Post".
- @index - Used on a field in a database to define that a given field should be an index. Takes an optional name argument that can be leveraged to define compound indexes.
- @autoincrement - Used on a field to say it should be autoincrement primary key.
- @key - Used on a field to define it as the primary key.
- @unique - Used on a field to declare that values of that field should be unique for each entry. This should be removed and added as an argument to the
@index
annotation.
database Blog @version(1) {
table Posts @item("Post") {
@autoincrement id: number;
@index @index("title_author") @unique title: string;
content: string;
@index("title_author") author: string;
}
}
This schema defines a Blog database with version 1. A table "Posts" where every entry in the table is a "Post". The primary key is the "id" field and it is an autoincrement field. Two indexes are defined, one "title" and one "title_author". The "title_author" index is a compound index allowing us to search by both the title and author fields. The title for each entry must also be unique.
It is possible to define default values for fields in the schema. For example, if in our blog application we create an entry in the DB once the user creates a title, but before they write any body content we would default the body content to an empty string to avoid needing to set that explicitly.
database Blog @version(1) {
table Posts @item("Post") {
@autoincrement id: number;
@index @index("title_author") @unique title: string;
content: string = "";
@index("title_author") author: string;
}
}
Default values are enforced on write. This results in the fields with defaults being optional on write, but being required on read.
export type Post = {
id: number;
title: string;
content: string;
author: string;
};
export type PostAddArgs = {
id: number;
title: string;
content?: string;
author: string;
};
Default values are actually saved to the DB at the time of write for fields where data is not provided.
IndexedDB is a document store meaning that it stores objects and not the tables we find in relational databases where join operations are common. In document stores sometimes we store redundant data in the name of keeping things simple. However, sometimes it does make sense to eliminate the redundancy.
Extending from our Blog example... let's say we want to store more information about our authors than name and we want the association between Posts and Authors.
We could create a table for "Authors" and then store the primary key of the Author for each item in our "Posts" table. That would look something like this:
database Blob {
table Authors {
@autoincrement id: number;
firstName: string;
lastName: string;
}
table Posts {
@autoincrement id: number;
@index title: string;
content: string;
author: number;
}
}
Our generated client can automate some of this (with the intention of supporting more automation). Let's update our schema to look like this:
database Blob {
table Authors @item("Author") {
@autoincrement id: number;
firstName: string;
lastName: string;
}
table Posts {
@autoincrement id: number;
@index title: string;
content: string;
author: Author;
}
}
You'll notice I changed the type of author
in the Posts
table to be Author
. I also added an item annotation to Authors
. I could have made the type of author
to be Authors
, but the singular reads better. By defining the type of a field as another table I'm setting up a relationship between these two tables. When storing data a primary key to the Authors
table will be stored in the author
field in the Posts
table. When performing a get
the client will automatically fetch the author and merge the objects so the author field will actually hold the author object.
In the generated code there are two types created for Posts
:
type Posts = {
id: number;
title: string;
content: string;
author: number;
};
type PostsWithJoins = {
id: number;
title: string;
content: string;
author: Author;
};
By default get
operations will return the second object. You can disable this by setting withJoins
to false
. The get
operations take an optional second argument that allows you to define some additional options for your query.
const postWithAuthor: PostsWithJoins = await db.posts.get({id: 1});
const postWithoutAuthor: Posts = await db.posts.get({id: 1}, {withJoins: false});
Automated joins also work for add
and put
operations. Working still from out Blog
example we don't have to already have an Author
in the Authors
table... we can combine our insert of both a Post
and an Author
into one operation.
const postWithAuthor: PostsAddArgs = {
title: 'First Post',
content: 'Nothing special',
author: {
firstName: 'Kevin',
lastName: 'Greene',
}
}
const result: Posts = await db.posts.add(postWithAuthor);
Note the return type of the add
operation. It is the Posts
type. That means the author
field on the returned object with contain the primary key of the Author
that was added to the Authors
table.
If there are nested joins used in a single add
or put
operation in order to get the primary key of objects added down the tree you would need to do those lookups yourself. For example, look up the added Author
with the returned id
and then look at the primary keys of anything inserted on that Author
.
Currently there are two ways to fetch more than one item from the database.
- Using
sortBy
to fetch all entries from database sorted by a given index. This can be limited to a given count if you have a large data set. - Using
RangeQuery
to fetch items in a given range. Range queries are exposed through thewhere
method.
The sortBy
method takes as an argument an index in the given store and will return all entries sorted by that index.
To get all Posts
sorted by title
.
const posts: Array<PostsWithJoins> = await db.posts.sortBy('title');
The sortBy
method takes an optional second parameter that will allow you to limit the number of items returned.
const posts: Array<PostsWithJoins> = await db.posts.sortBy('title', { count: 10 });
The ability to get multiple items from the database is exposed through range queries. Range queries are performed by calling the where
method.
const results: Array<PostsWithJoins> = await db.posts.where('id').isGreaterThan(4);
const results: Array<PostsWithJoins> = await db.posts.where('id').isGreaterThanOrEqualTo(4);
const results: Array<PostsWithJoins> = await db.posts.where('id').isLessThan(4);
const results: Array<PostsWithJoins> = await db.posts.where('id').isLessThanOrEqualTo(4);
const results: Array<PostsWithJoins> = await db.posts.where('id').isBetween({from: 1, to: 7});
const results: Array<PostsWithJoins> = await db.posts.where('id').isEqualTo(4);
The where
method takes an index (or key) from the database and returns an object that allows you to construct range queries on that index.
The supported methods are:
isGreaterThan
- Get all elements where given index is greater than provided argument.isGreaterThanOrEqualTo
- Get all elements where given index is greater than or equal to the provided argument.isLessThan
- Get all elements where given index is less than provided argument.isLessThanOrEqualTo
- Get all elements where given index is less than or equal to the provided argument.isBetween
- Get all elements where given index is between two arguments.isEqualTo
- Get all elements where given index is equal to the given argument.
The returned methods are type safe based on the index name provided. For example, if the index you provide is of type string
you can't pass a number
.
Often parts of your app will want to respond to database changes made in other parts of your app. To help facilitate this a subscribe
method is exposed.
The subscribe method takes two arguments:
eventName
- What kind of update are you interested in.change
- Subscribe to all changes to a table.add
- Subscribe when an object is added to a table.put
- Subscribe when an object is updated in the table.delete
- Subscribe when an object is deleted from the table.
callback
- A function to call when the updates you are interested in happen. This callback receives aSubscriptionEvent
. TheSubscriptionEvent
has the following type:
type SubscriptionEvent<ItemType, PrimaryKeyType> = {
type: 'delete';
data: PrimaryKeyType;
} | {
type: 'add' | 'put';
data: ItemType;
}
An example usage:
db.posts.subscribe('add', (e) => {
const newListItem = renderPostListItem(e.data);
postList.append(newListItem);
});
Calling the exported init
function returns our database client. This client currently only supports a subset of IndexedDB features.
const addedPost = await db.posts.add({
title: 'Test',
content: 'Some content',
author: 1,
})
const updatedPost = await db.posts.put({
id: 4
title: 'New Title',
content: 'Some content',
author: 1,
})
await db.posts.delete({
id: 4,
})
const postWithJoins = await db.posts.get({
id: 4,
})
const postWithJoins = await db.posts.where('title').isEqualTo('New Title')
This is just a wrappter around the built-in db.transaction
function that provides a bit of typesafety by ensuring you only ask for transactions for tables defined in your schema.