Skip to content

Using atscript-db Models

aoothjs ships three .as models that you extend in your app. This page covers what each model contributes, how to add columns, and how to wire syncSchema() to materialise the tables.

The patterns below are taken from packages/e2e-demo/.

The three shipped models

ModelSubpathPurpose
AoothUserCredentials@aooth/user/atscript-db/model.asBase credential record — the surrogate id (PK / token subject), the unique username handle, version, password, account, mfa, trustedDevices. Only @db.table is left to the app. Secondary login/recovery handles (email, phone) are consumer-declared — see Credentials Model.
AoothArbacUserCredentials@aooth/arbac-moost/atscript/models.asExtends AoothUserCredentials with @arbac.role roles: string[]. The default user shape when ARBAC is in play.
AoothAuthCredential@aooth/auth/atscript-db/model.asBearer-token row — token (PK), userId, issuedAt, expiresAt, claims, metadata. Already complete — apps usually do not extend it.

Subpath form

The package.json exports the raw .as source under the .as suffix (e.g. '@aooth/user/atscript-db/model.as'). That is what unplugin-atscript resolves and what the e2e demo imports. There is no separate no-extension subpath — always include .as.

What's in AoothUserCredentials

ts
export interface AoothUserCredentials {
    @meta.id
    @db.default.uuid
    id: string

    @db.index.unique 'username_idx'
    username: string

    @db.column.version
    version: number.int

    @db.patch.strategy 'merge'
    password: { hash, history, lastChanged, isInitial }

    @db.patch.strategy 'merge'
    account: { active, locked, lockReason, lockEnds,
               failedLoginAttempts, lastLogin, pendingInvitation? }

    @db.patch.strategy 'merge'
    mfa: { methods, defaultMethod, autoSend }

    @db.patch.strategy 'merge'
    trustedDevices?: { token, ip?, issuedAt, expiresAt, name? }[]
}

Source: user-credentials.as.

Two things to notice:

  1. The surrogate id is the PK (@meta.id + @db.default.uuid) and the token subject — what useAuth().getUserId() returns and what ARBAC resolves a user by. username is the one base login handle (@db.index.unique), always present. Secondary login/recovery handles (email, phone) are not in the base — you declare your own email/phone field on your extending model, index it @db.index.unique, and tag it @aooth.user.email / @aooth.user.phone. The only thing left to the app for the base is @db.tabledon't redeclare id. See Credentials Model and Phone, Recovery Channels & Handles.
  2. @db.patch.strategy 'merge' on password, account, mfa, trustedDevices. This is how the UsersStoreAtscriptDb adapter knows that a set patch like { account: { lastLogin } } should merge into the existing row, not replace the whole sub-object. Do not redeclare these sub-objects without the same annotation — TypeScript shape inheritance does not carry the atscript annotation. See the @aooth/user invariants.

Extending for ARBAC

For ARBAC apps, extend AoothArbacUserCredentials instead — it pre-applies @arbac.role to roles: string[] so you do not have to remember the annotation:

ts
import { AoothArbacUserCredentials } from '@aooth/arbac-moost/atscript/models'
import { Tenant } from './tenant'
import { Department } from './department'

@db.table 'users'
@db.http.path '/users'
export interface DemoUser extends AoothArbacUserCredentials {
    // id (PK / @meta.id), username (unique handle), version — inherited.
    @arbac.attribute
    @meta.required
    @db.rel.FK
    tenantId: Tenant.id

    @arbac.attribute
    @db.rel.FK
    departmentId?: Department.id

    @expect.maxLength 80
    displayName?: string

    // Secondary login/recovery handles — consumer-declared, not in the base.
    // Each MUST carry @db.index.unique (the account-takeover guard).
    @db.index.unique 'email_idx'
    @aooth.user.email
    email?: string

    @expect.maxLength 32
    @db.index.unique 'phone_idx'
    @aooth.user.phone
    phone?: string

    @db.default.now
    createdAt: number.timestamp
}

Source: e2e-demo/src/models/user.as.

The annotations that matter:

AnnotationEffect
@db.table 'users'Names the SQL/Mongo table. Required on the concrete model — the base ships without it (it's the only thing your extension must add).
@meta.id + @db.default.uuidMarks the PK (id) — inherited from the base, don't redeclare. UserService.createUser mints the id (randomUUID), so the @db.default.uuid fires only for direct inserts that bypass the service.
@arbac.attributeEvery field marked here becomes a key in the UserAttrs map passed to role scopes — attrs.tenantId is available inside defineRole().use(allowTableRead(..., { scope: (attrs) => ({ filter: { tenantId: attrs.tenantId } }) })).
@arbac.attenuate.role (credential)On a credential model — marks the one typed field holding a down-scoped token's assumed-role SUBSET (string[]). Intersected with the user's roles, fail-closed. Read via extractAttenuation for restrict-only attenuation.
@arbac.attenuate.attr "userAttr" (credential)On a credential model — marks a typed field that narrows the named user attribute ("userAttr" = the target @arbac.attribute key, boot-validated). Multiple fields may target different attrs.
@db.rel.FKOptional — declares a foreign-key relationship. Not required for ARBAC to work; useful when you want @atscript/moost-db to validate references.
@expect.maxLength / @expect.patternValidation constraints surfaced through @atscript/moost-validator.
@db.default.nowAdapter sets the value to current epoch ms on insert.

One @arbac.role field per user

The AtscriptArbacUserProvider fails loud if it sees more than one field with @arbac.role. The bundled AoothArbacUserCredentials already declares one — do not redeclare in your extending interface.

A pure-authn user (no ARBAC)

If you do not use the moost ARBAC layer, extend AoothUserCredentials directly:

ts
import { AoothUserCredentials } from '@aooth/user/atscript-db/model.as'

@db.table 'users'
export interface AppUser extends AoothUserCredentials {
    // id (PK), username (unique handle), version — inherited.
    // Declare your own email/phone handle here if you want login/recovery by them
    // (index it @db.index.unique + tag @aooth.user.email / @aooth.user.phone).
    @db.default.now
    createdAt: number.timestamp
}

Everything in UserService, AuthWorkflow, etc. still works — RBAC just is not enforced.

The bundled credential table

For token storage, AoothAuthCredential is already complete — @meta.id, @db.table 'aooth_credentials', @db.depth.limit 0. Import and use as-is:

ts
import { AoothAuthCredential } from '@aooth/auth/atscript-db/model.as'
import { CredentialStoreAtscriptDb } from '@aooth/auth/atscript-db'

You normally do not extend it. The full schema is in auth-credential.as.

Sync the schema

syncSchema() reads the atscript-derived schema and creates / migrates the tables. Idempotent and safe to call on every boot. It acquires the __atscript_control lock so concurrent boots do not race.

ts
import { DbSpace } from '@atscript/db'
import { syncSchema } from '@atscript/db/sync'
import { BetterSqlite3Driver, SqliteAdapter } from '@atscript/db-sqlite'
import { AoothAuthCredential } from '@aooth/auth/atscript-db/model.as'
import { DemoUser } from './models/user.as'
import { Tenant } from './models/tenant.as'
import { Department } from './models/department.as'

const driver = new BetterSqlite3Driver('./app.db')
const db = new DbSpace(() => new SqliteAdapter(driver))

const tables = {
  users:       db.getTable(DemoUser),
  credentials: db.getTable(AoothAuthCredential),
  tenants:     db.getTable(Tenant),
  departments: db.getTable(Department),
}

await syncSchema(db, [
  Tenant,
  Department,
  DemoUser,
  AoothAuthCredential,
])

Source: e2e-demo/src/db.ts.

Pass every model

syncSchema only touches the models you pass. Forgetting AoothAuthCredential is the most common cause of "no such table: aooth_credentials" at first login — atscript cannot infer dependent tables from a reference.

Wire the stores

The shipped adapters expect typed table handles. AtscriptDbTable<T> returns Record<string, unknown> from its structural reads, so wiring needs a single cast at the boundary:

ts
import { AuthUserTable, UsersStoreAtscriptDb } from '@aooth/user/atscript-db'
import { CredentialStoreAtscriptDb } from '@aooth/auth/atscript-db'

const userStore = new UsersStoreAtscriptDb<DemoUser>({
  table: tables.users as unknown as AuthUserTable<DemoUser>,
})

const credentialStore = new CredentialStoreAtscriptDb({
  table: tables.credentials,
})

Source: e2e-demo/src/aooth.ts.

Register the atscript plugin

atscript.config.mts registers the plugins responsible for compiling each namespace of annotations. The arbacPlugin() export registers @arbac.role, @arbac.attribute, @arbac.userId, and the credential-side @arbac.attenuate.role / @arbac.attenuate.attr. Without it, the compiler emits unknownAnnotation warnings (or errors, depending on your config) on every @arbac.* reference.

ts
import arbacPlugin from '@aooth/arbac-moost/plugin'
import { defineConfig } from '@atscript/core'
import dbPlugin from '@atscript/db/plugin'
import wfPlugin from '@atscript/moost-wf/plugin'
import ts from '@atscript/typescript'

export default defineConfig({
  rootDir: 'src',
  plugins: [ts(), dbPlugin(), wfPlugin(), arbacPlugin()],
  format: 'dts',
  unknownAnnotation: 'warn',
})

Source: e2e-demo/atscript.config.mts.

Compile-time only

@aooth/arbac-moost/plugin contributes no runtime code. Importing it from application code by mistake will fail — it only lives in atscript.config.mts. The runtime ARBAC machinery is at @aooth/arbac-moost (decorators, interceptor) and @aooth/arbac-moost/atscript (AtscriptArbacUserProvider).

Letting the provider read your model

AtscriptArbacUserProvider introspects the user .as type once (cached in a WeakMap per type) to find:

  1. The user-id field — resolved via @arbac.userId → field of @db.table.preferredId.uniqueIndex group → @meta.id. Constructor throws if none resolves.
  2. The single @arbac.role field — either an inline string | string[] or a @db.rel.from nav prop. Multi-role declarations fail loud.
  3. Every @arbac.attribute field — each becomes a key in the UserAttrs map.

Apps wire the provider by subclassing and overriding getUserId(). The auth subject is the stable surrogate id, which is the base model's @meta.id — so the provider's default identifier chain (@arbac.userId@db.table.preferredId.uniqueIndex@meta.id) already resolves to id. No @arbac.userId annotate is needed — just pass the table:

ts
import { AtscriptArbacUserProvider, type ArbacUserTable } from '@aooth/arbac-moost/atscript'
import { useAuth } from '@aooth/auth-moost'
import { Injectable } from 'moost'
import { DemoUser } from './models/user.as'

@Injectable()
class AppArbacUserProvider extends AtscriptArbacUserProvider<DemoUser> {
  constructor() {
    // Provider resolves users by `@meta.id` (= `id` = the auth subject) — no shim.
    super(DemoUser, db.getTable(DemoUser) as unknown as ArbacUserTable<DemoUser>)
  }
  override getUserId(): string {
    return useAuth().getUserId() // the stable `id`
  }
}

@Injectable() must be re-applied on the subclass — moost@0.6.x does not inherit injectable metadata across extends.

The stable id is the auth subject

useAuth().getUserId() returns the surrogate id — what AuthWorkflow passes to auth.issue(subject) (the token sub claim), not the username. Because that id is the model's @meta.id, the provider's identifier chain already matches the subject with no annotation. db.getTable(DemoUser) is structurally compatible with ArbacUserTable<DemoUser> but typed wider — the cast is required at compile time only.

Migrating from a @arbac.userId username shim

Earlier releases keyed the token subject on username and required an annotate AoothUserCredentials { @arbac.userId username } block to point ARBAC lookups at it. The id-subject re-key removed that: delete the annotate block — leaving it points the provider at username while the subject is now the id, so every lookup 401s with "user not found".

Generated artefacts

Running asc -f dts (or unplugin-atscript in your bundler) produces three siblings per .as file:

FilePurpose
user.as.d.tsTypeScript types — so import { DemoUser } from './user.as' type-checks.
user.as.jsRuntime metadata — the class object that db.getTable(DemoUser) and AtscriptArbacUserProvider(DemoUser, ...) consume.
atscript.d.tsProject-level merged-type declarations.

Re-run after every .as change

We recommend gitignoring the compiled artefacts. Add gen:atscript to your prebuild / predev hook, or rely on unplugin-atscript to run it automatically.

Recap

  1. Extend AoothArbacUserCredentials (or AoothUserCredentials) with your own columns — id (PK), username, and version are inherited; add @db.table (and your own @db.index.unique + @aooth.user.email/@aooth.user.phone handle fields if you want login/recovery by email/phone).
  2. Mark scope keys with @arbac.attribute.
  3. No @arbac.userId annotate needed — the auth subject is the base @meta.id (id), which the provider resolves by default.
  4. Pass the model to syncSchema() along with AoothAuthCredential.
  5. Wire UsersStoreAtscriptDb and CredentialStoreAtscriptDb against the resulting tables.
  6. Register arbacPlugin() in atscript.config.mts so @arbac.* annotations type-check.
  7. Subclass AtscriptArbacUserProvider and override getUserId(). Pass the table directly — no shim, no annotate.

Next steps

Released under the MIT License.