loceldocumented
Reference

@locel/adonisjs

@locel/adonisjs — provider, config, the Lucid shortcut, routes and ace commands.

The AdonisJS integration. A provider, a defineConfig, a Lucid shortcut, routes and commands. It contains no sync logic — everything of substance lives in @locel/server, and this package wires it to the framework.

Status: Built. Provider, config, manager, routes, commands, stores.lucid and withSync, and the routes are exercised over real HTTP against a real AdonisJS server. The configure stubs are the one part with no test behind them — they run once, at node ace add, and what they write is checked by reading it.

Installation

node ace add @locel/adonisjs

defineConfig(config)

config/locel.ts
import env from '#start/env'
import { defineConfig, stores, type InferLocelStores } from '@locel/adonisjs'
import schema from '#sync/schema'

const locelConfig = defineConfig({
  schema,
  default: env.get('LOCEL_STORE'),
  stores: {
    lucid: stores.lucid({ connectionName: 'postgres' }),
    sqlite: stores.sqlite({ path: env.get('LOCEL_DB_PATH') }),
    memory: stores.memory(),
  },
  partitionColumns: ['workspaceId'],
  partition: (ctx) => ({ workspaceId: ctx.auth.getUserOrFail().currentWorkspaceId }),
  principal: (ctx) => ctx.auth.getUserOrFail().id,
  policies: {
    todos: {
      write: (mutation, ctx) => mutation.type !== 'delete' || ctx.auth.getUserOrFail().isAdmin,
    },
  },
  prefix: '/sync',
})

export default locelConfig

declare module '@locel/adonisjs/types' {
  export interface LocelStoresList extends InferLocelStores<typeof locelConfig> {}
}
OptionTypeRequiredDescription
schemaSchemaFrom defineSchema, in a file with no framework imports.
defaultkeyof storesWhich store to use.
storesRecord<string, ConfigProvider<Store>>Named stores. Each is resolved from the container at boot, never while this file is evaluated.
partitionColumnsstring[]The partition columns, by name.
partition(ctx: HttpContext) => ScopeThe values for one request. Derived from the session. Never reads the request body.
principal(ctx: HttpContext) => stringThe signed-in subject. Defaults to ctx.auth.getUserOrFail().id. Keeps two users on one browser in separate local stores — override only if your identity is not the Lucid user id.
policiesPoliciesPer-collection write refusals.
prefixstringRoute prefix. Default /sync.
writerHeaderstringDefault x-locel-writer.
modeHeaderstringDefault x-locel-mode.
pageSizenumberRows per pull response.

partitionColumns and partition are both required, and they answer different questions. The authority needs the names before any request exists — it checks at boot that every collection carries all of them, and that none of them is sealed. partition returns values, and values only exist once somebody has asked for something.

defineConfig returns its argument unchanged; its only job is inference. The declare module block feeds your config's shape back into locel's types, so locel.use('sqlite') autocompletes and an unknown name is a compile error.

Stores

stores.lucid({ connectionName?, tablePrefix? })   // your application database
stores.knex(knex, { tablePrefix? })               // an app-owned Knex instance
stores.kysely(db, { tablePrefix? })               // an app-owned Kysely instance
stores.sqlite({ path })                           // a database locel owns (preset over kysely)
stores.memory()                                   // tests (preset over sqlite, in memory)

Each returns a config provider rather than a store. Nothing is constructed while config/locel.ts is being evaluated; the container resolves the connection during boot, and the driver is imported only if the store is the one you selected. That is what lets stores.lucid() depend on @adonisjs/lucid without @locel/adonisjs importing it.

stores.lucid

There is no Lucid driver. stores.lucid() resolves lucid.db, takes the connection's getWriteClient() — the underlying Knex instance — and hands it to stores.knex in @locel/server:

database(config) {
  return configProvider.create(async (app) => {
    const db = await app.container.make('lucid.db')
    const connection = db.connection(config?.connectionName ?? db.primaryConnectionName)
    return stores.knex(connection.getWriteClient(), config)
  })
}

So every dialect Lucid supports is supported here, through one adapter, and the integration package keeps holding no logic.

Synced rows live in your own tables, and the rest of your application reads them with Lucid as usual. The store needs to know which columns carry the row header, which the withSync mixin declares:

app/models/todo.ts
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { withSync } from '@locel/adonisjs'
import { todos } from '#sync/schema'

export default class Todo extends compose(BaseModel, withSync(todos)) {
  @column({ isPrimary: true })
  declare id: string

  @column()
  declare workspaceId: string

  @column()
  declare title: string
}

The mixin adds the row header columns, the tombstone filter on default queries, and a static syncCollection the store reads. It does not change how you write to the model outside sync — a server-side write through Lucid is stamped with the next sequence number and broadcast, so an admin action shows up on every connected client without going through the sync path.

Container service

import locel from '@locel/adonisjs/services/main'

const session = await locel.session(ctx)
await session.push({ transactions })

await locel.use('memory')   // a different store, typed from your config
await locel.authority       // the underlying Authority

All three are async: an authority is built the first time its store is asked for, and building one resolves a config provider from the container.

Routes

The provider registers, under prefix:

MethodPathPurpose
GET/sync/helloSchema hash, protocol version, and the session's scope and principal
GET/sync/pullChanges after a checkpoint
POST/sync/pushApply transactions
GET/sync/streamServer-sent events: changes, evictions, acknowledgements
POST/sync/subsetsOpen a subset
DELETE/sync/subsets/:idClose a subset

They are ordinary routes. Apply your own middleware:

start/routes.ts
import locelRoutes from '@locel/adonisjs/routes'

locelRoutes().use(middleware.auth())

Authentication is yours. locel never sees a credential — it sees the scope your partition function returned.

Commands

CommandDescription
node ace locel:migrateWith an owned store, creates the tables. With a borrowed one it refuses: those tables are yours to version, and locel does not run DDL against a database it is a guest in.
node ace locel:migrate --printThe SQL instead, for your own migration history. --dialect=sqlite|postgres|mysql and --format=sql|knex|kysely.
node ace locel:doctorCompare every collection against the table behind it and report differences — a missing table, a missing column, a missing partition or row header column, a nullable primary key. Indexes are not compared: the introspection does not report them, and a check that quietly covers less than it claims is worse than one that says what it covers.
node ace locel:inspectPrint the authority's view: schema hash, collections, partition columns, store, current sequence number

locel:doctor exits non-zero on any difference. Run it in CI — a schema and a database that disagree is the failure that reaches production silently.

Testing

import { test } from '@japa/runner'
import locel from '@locel/adonisjs/services/main'

test('a write is refused outside the partition', async ({ assert }) => {
  const session = (await locel.use('memory')).session({
    scope: { workspaceId: 'w1' },
    principal: 'u_1',
    writer: 'w_test',
  })

  const { results } = await session.push({
    transactions: [transactionFor({ workspaceId: 'w2' })],
  })

  assert.equal(results[0].outcome, 'rejected')
  assert.equal(results[0].code, 'E_ROW_OUTSIDE_PARTITION')
})

push resolves whatever happened; a refused transaction is a rejected outcome in results, not a thrown error. Only a malformed request throws.

On this page