Skip to content

Base SQLite DataSource

BaseSqliteSqlDataSource is the base class provided by Xeno.JS for implementing data sources that work with SQLite-compatible databases through Drizzle ORM and the libSQL client.

The class does not implement CRUD operations and does not define methods such as findById() or save().

Instead, it provides protected access to the database:

protected get db(): LibSQLDatabase<TSchema>

A concrete data source extends the class and uses this.db to execute Drizzle queries.

Application
│
├── AppBuilder.addDb()
│ │
│ ├── enableSqlLite = true
│ └── @libsql/client + Drizzle
│
├── TOKENS.DB_CONTEXT
│
└── Custom DataSource
│
└── BaseSqliteSqlDataSource
│
└── this.db

The public class is exported by @xeno-js/core as:

BaseSqliteSqlDataSource

To use the SQLite/libSQL database path, the application needs:

  • @xeno-js/core;
  • drizzle-orm;
  • @libsql/client.

The Xeno.JS package declares drizzle-orm and @libsql/client as optional peer dependencies, so applications using this database integration must install the packages they require.

Terminal window
npm install @xeno-js/core drizzle-orm @libsql/client

Xeno.JS currently uses the @libsql/client client together with the Drizzle drizzle-orm/libsql integration.

Define the tables using the Drizzle SQLite schema API and collect them into an application schema.

For example:

src/infrastructure/db/schema.ts
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const users = sqliteTable('users', {
id: integer('id').primaryKey(),
email: text('email').notNull(),
})
export const dbSchema = {
users,
}
export type ProjectDbSchema = typeof dbSchema

The schema can then be used as the generic parameter of BaseSqliteSqlDataSource.

Using the schema type allows the database exposed through this.db to retain the application’s Drizzle schema typing.

A concrete data source extends BaseSqliteSqlDataSource.

src/infrastructure/datasources/user.datasource.ts
import { eq } from 'drizzle-orm'
import type { BaseSqliteSqlDataSource } from '@xeno-js/core/db'
import { users } from '../db/schema'
import type { ProjectDbSchema } from '../db/schema'
export class UserDataSource extends BaseSqliteSqlDataSource<ProjectDbSchema> {
public async findById(id: number) {
const [user] = await this.db
.select()
.from(users)
.where(eq(users.id, id))
return user
}
}

The important part is:

this.db

db is protected, so it is available to the concrete data source but is not exposed as part of the public API of the resolved data source.

The data source does not need to create a LibSQLDatabase manually.

The constructor of the base class receives a DbContext<TSchema>:

export abstract class BaseSqliteSqlDataSource<
TSchema extends Dictionary = Dictionary,
> {
constructor(private readonly _db: DbContext<TSchema>) {}
protected get db(): LibSQLDatabase<TSchema> {
return this._db as LibSQLDatabase<TSchema>
}
}

DbContext<TSchema> is Xeno.JS’s database context type. It is a union that supports both:

  • NodePgDatabase<TSchema> for PostgreSQL;
  • LibSQLDatabase<TSchema> for SQLite/libSQL.

For BaseSqliteSqlDataSource, the protected getter narrows the context to:

LibSQLDatabase<TSchema>

A concrete data source therefore receives TOKENS.DB_CONTEXT from the dependency injection container.

new UserDataSource(container.resolve(TOKENS.DB_CONTEXT))

SQLite/libSQL is configured through AppBuilder.addDb().

The important configuration option is:

enableSqlLite: true

For example:

const builder = new AppBuilder<AppRegistry>()
builder.addDb((options, config) => {
options.connectionString = config.getOrThrow('DATABASE_URL')
options.enableSqlLite = true
})

addDb() initializes the database configuration with:

{
connectionString: '',
enableSqlLite: false,
}

Therefore, SQLite/libSQL must be explicitly enabled.

When enableSqlLite is true, Xeno.JS uses its SQLite/libSQL database path instead of the PostgreSQL path.

The resulting flow is:

AppBuilder.addDb()
│
├── enableSqlLite = true
│
▼
DbModule
│
▼
DbUtils.addSqlLite()
│
▼
DbSqlLiteClientFactory
│
├── @libsql/client
└── drizzle-orm/libsql
│
▼
DbContext

The SQLite/libSQL factory creates the client with the configured connection string:

const client = createClient({
url: opts.connectionString,
})

The client is then passed to Drizzle:

return drizzle({ client })

Therefore the value assigned to connectionString is passed directly to @libsql/client as its url.

For example:

builder.addDb((options, config) => {
options.connectionString = config.getOrThrow('DATABASE_URL')
options.enableSqlLite = true
})

The exact URL format depends on the libSQL client configuration being used by the application.

After configuring the database, register the concrete data source through addServices().

import { AppBuilder, TOKENS } from '@xeno-js/core'
import type { AppRegistry } from './infrastructure/xeno-registry/app-registry'
import { UserDataSource } from './infrastructure/datasources/user.datasource'
const builder = new AppBuilder<AppRegistry>()
builder
.addDb((options, config) => {
options.connectionString = config.getOrThrow('DATABASE_URL')
options.enableSqlLite = true
})
.addServices((services) => {
services.addScoped('USER_DATA_SOURCE', (container) => {
return new UserDataSource(
container.resolve(TOKENS.DB_CONTEXT),
)
})
})

USER_DATA_SOURCE is an application-specific token. It is not a built-in Xeno.JS token.

The token should therefore be added to the application’s registry.

Use XenoDbRegistry with the application’s Drizzle schema.

src/infrastructure/xeno-registry/app-registry.ts
import type { XenoDbRegistry } from '@xeno-js/core/db'
import type { ProjectDbSchema } from '../db/schema'
import type { UserDataSource } from '../datasources/user.datasource'
export interface AppRegistry extends XenoDbRegistry<ProjectDbSchema> {
USER_DATA_SOURCE: UserDataSource
}

The database schema is supplied as the generic parameter:

XenoDbRegistry<ProjectDbSchema>

The registry can then contain application-specific dependency injection tokens such as:

USER_DATA_SOURCE

Complete the application bootstrap with build():

const container = await builder.build()

Because the data source is registered as scoped, resolve it from a scope:

const scope = container.createScope()
const dataSource = scope.resolve('USER_DATA_SOURCE')
const user = await dataSource.findById(42)
await scope.dispose()

The scope is important because Xeno.JS registers DB_CONTEXT as a scoped service.

The relationship is:

Scope
│
├── TRANSACTION_STATE
│
├── DB_CONTEXT
│
└── USER_DATA_SOURCE

A minimal application structure can be:

src/
├── bootstrap.ts
├── domain/
│ └── ...
└── infrastructure/
├── db/
│ └── schema.ts
├── datasources/
│ └── user.datasource.ts
└── xeno-registry/
└── app-registry.ts
src/infrastructure/db/schema.ts
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const users = sqliteTable('users', {
id: integer('id').primaryKey(),
email: text('email').notNull(),
})
export const dbSchema = {
users,
}
export type ProjectDbSchema = typeof dbSchema
src/infrastructure/xeno-registry/app-registry.ts
import type { XenoDbRegistry } from '@xeno-js/core/db'
import type { ProjectDbSchema } from '../db/schema'
import type { UserDataSource } from '../datasources/user.datasource'
export interface AppRegistry extends XenoDbRegistry<ProjectDbSchema> {
USER_DATA_SOURCE: UserDataSource
}
src/infrastructure/datasources/user.datasource.ts
import { eq } from 'drizzle-orm'
import { BaseSqliteSqlDataSource } from '@xeno-js/core/db'
import { users } from '../db/schema'
import type { ProjectDbSchema } from '../db/schema'
export class UserDataSource extends BaseSqliteSqlDataSource<ProjectDbSchema> {
public async findById(id: number) {
const [user] = await this.db
.select()
.from(users)
.where(eq(users.id, id))
return user
}
}
src/bootstrap.ts
import { AppBuilder, TOKENS } from '@xeno-js/core'
import type { AppRegistry } from './infrastructure/xeno-registry/app-registry'
import { UserDataSource } from './infrastructure/datasources/user.datasource'
export async function bootstrap() {
const builder = new AppBuilder<AppRegistry>()
builder
.addDb((options, config) => {
options.connectionString = config.getOrThrow('DATABASE_URL')
options.enableSqlLite = true
})
.addServices((services) => {
services.addScoped('USER_DATA_SOURCE', (container) => {
return new UserDataSource(
container.resolve(TOKENS.DB_CONTEXT),
)
})
})
return builder.build()
}
const container = await bootstrap()
const scope = container.createScope()
const dataSource = scope.resolve('USER_DATA_SOURCE')
const user = await dataSource.findById(42)
await scope.dispose()

BaseSqliteSqlDataSource is useful when a data source needs to execute SQLite/libSQL-specific queries through Drizzle.

For example:

export class UserDataSource extends BaseSqliteSqlDataSource<ProjectDbSchema> {
public async findAll() {
return this.db
.select()
.from(users)
}
}

The base class does not impose a particular application-level API on the data source.

A concrete data source can therefore contain:

  • specific queries;
  • filters;
  • joins supported by the configured Drizzle dialect;
  • aggregations;
  • insert operations;
  • update operations;
  • delete operations;
  • queries used by Repository or ReadDao.

The responsibility for implementing those queries remains with the concrete data source.

BaseSqliteSqlDataSource vs BasePostgresSqlDataSource

Section titled “BaseSqliteSqlDataSource vs BasePostgresSqlDataSource”

Xeno.JS exposes separate base data source classes for PostgreSQL and SQLite/libSQL.

For PostgreSQL:

BasePostgresSqlDataSource
↓
NodePgDatabase<TSchema>

For SQLite/libSQL:

BaseSqliteSqlDataSource
↓
LibSQLDatabase<TSchema>

The corresponding database clients are also different.

The SQLite/libSQL path uses:

@libsql/client
↓
drizzle-orm/libsql
↓
LibSQLDatabase

The PostgreSQL path uses the PostgreSQL Drizzle integration instead.

Choose the base data source that corresponds to the database path configured through addDb().

BaseSqliteSqlDataSource is intended for the SQLite/libSQL path.

The database must therefore be configured with:

options.enableSqlLite = true

For example:

builder.addDb((options, config) => {
options.connectionString = config.getOrThrow('DATABASE_URL')
options.enableSqlLite = true
})

If enableSqlLite remains false, Xeno.JS uses the PostgreSQL database factory instead.

The two paths are selected inside DbModule:

const db = opts.enableSqlLite
? await DbUtils.addSqlLite(opts)
: await DbUtils.addDbClient(opts)

This means the setting determines which implementation of DbContext is created.

DB_CONTEXT is registered by DbModule as a scoped service.

Xeno.JS also registers TRANSACTION_STATE as scoped and creates DB_CONTEXT using that transaction state.

When there is an active transaction, the DB_CONTEXT proxy delegates database operations to the active transaction state.

Conceptually:

Scope
│
├── TRANSACTION_STATE
│ │
│ └── active transaction
│
└── DB_CONTEXT
│
├── normal database
│
└── active transaction when present

A data source that receives DB_CONTEXT should therefore be registered as scoped when it captures that scoped dependency:

services.addScoped('USER_DATA_SOURCE', (container) => {
return new UserDataSource(
container.resolve(TOKENS.DB_CONTEXT),
)
})

This allows the data source to receive the DB_CONTEXT belonging to the current scope.

db is protected.

It cannot be accessed through the resolved data source:

const dataSource = scope.resolve('USER_DATA_SOURCE')
dataSource.db // not accessible

It is available inside the concrete data source:

class UserDataSource extends BaseSqliteSqlDataSource<ProjectDbSchema> {
public query() {
return this.db.select().from(users)
}
}

Check that addDb() explicitly enables the SQLite/libSQL path:

builder.addDb((options, config) => {
options.connectionString = config.getOrThrow('DATABASE_URL')
options.enableSqlLite = true
})

If enableSqlLite is left as false, Xeno.JS selects the PostgreSQL database factory.

Verify that the application has installed:

Terminal window
npm install @libsql/client

@libsql/client is an optional peer dependency of @xeno-js/core.

Verify that drizzle-orm is installed:

Terminal window
npm install drizzle-orm

The SQLite data source imports:

import type { LibSQLDatabase } from 'drizzle-orm/libsql'

and the Xeno.JS SQLite database factory uses:

import { drizzle } from 'drizzle-orm/libsql'

Make sure the database module has been configured:

builder.addDb(...)

before:

await builder.build()

DB_CONTEXT is registered by DbModule, which is queued by addDb().

The data source is resolved from the root container

Section titled “The data source is resolved from the root container”

If the data source is registered with:

services.addScoped(...)

resolve it through a scope:

const scope = container.createScope()
const dataSource = scope.resolve('USER_DATA_SOURCE')

Then dispose of the scope when it is no longer needed:

await scope.dispose()

The public class is:

BaseSqliteSqlDataSource

and it is exported from @xeno-js/core.

[Unverified] The current repository source file is named:

base-sqllite.datasource.ts

The filename contains sqllite, while the public class name uses Sqlite:

BaseSqliteSqlDataSource

For application documentation and imports from @xeno-js/core, use the public class name rather than relying on the internal source filename.

To implement a SQLite/libSQL DataSource:

  • install @xeno-js/core;
  • install drizzle-orm;
  • install @libsql/client;
  • define the Drizzle SQLite tables;
  • create the application database schema type;
  • specialize XenoDbRegistry<ProjectDbSchema>;
  • create a class extending BaseSqliteSqlDataSource<ProjectDbSchema>;
  • use this.db for Drizzle queries;
  • configure addDb();
  • set options.enableSqlLite = true;
  • provide the database connection string;
  • register the data source with addServices();
  • use addScoped() when the data source depends on the scoped DB_CONTEXT;
  • resolve the data source from a scope;
  • dispose of the scope when finished.


Xeno.JS is an MIT-licensed open source project. It can grow thanks to the support of these awesome people. If you’d like to join them, please read more at support section