npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@assemora/database

v0.2.6

Published

Abstract database adapter contracts and Query AST execution

Readme

@assemora/database

Abstract database adapter contracts and Query AST execution.

Implementation phase: 3 — shipped early, in phase 2.

The Query AST (SPEC.md §30) and the adapter contract (SPEC.md §31) live here. They are the stable boundary between the data layer, database adapters, the policy layer and the AI query layer, and nothing in this package is specific to any engine.

const adapter = createMemoryAdapter({ users: [{ id: 'u1', active: true }] })

await adapter.execute(
  { model: 'users', operation: 'select', where: [comparison('active', '=', true)], order: [], with: [] },
  { table: usersDescriptor },
)

The in-memory adapter implements the same contract as @assemora/database-postgres, so a query proven in a unit test runs unchanged against PostgreSQL. It is for tests and development only — nothing it holds is durable.

Every relation kind is loaded in batches, belongsToMany included: one pass over the join table and one over the target, however many rows are being loaded for, and the same again per nested hop. adapter.diagnostics.scanCount() counts those passes — one per table, the way statementCount() counts statements on the PostgreSQL adapter — so an N+1 fails a test rather than waiting for a review (SPEC.md §89). What the two adapters have to agree the load means is settled in tests/integration/adapter-conformance.test.ts (ADR-0013).

A belongsToMany arrives ordered by the target's key, ascending, in both adapters. A join table has no order of its own, so without a stated one the same query answers with the same rows in different orders and user.roles[0] means one thing in a unit test and another in production. Every other kind carries whatever order its rows came back in; order in the Query AST sorts the rows a query selects, not the rows hanging off them.

Join tables

belongsToMany is the one relation that stores nothing on either table it links: the pairs live in a third table no model declares (SPEC.md §23). The data layer writes to it, the DDL creates it and the diff has to notice it arriving — so it is derived once, here, as an ordinary TableDescriptor.

joinTableDescriptor(User.descriptor, roles, Role.descriptor)
// name:            'roles_users'
// columns:         roleId, userId — both required, neither unique on its own
// uniqueTogether:  [['roleId', 'userId']]
// relations:       a belongsTo per side, so the table is created with real foreign keys

The name comes from through when the relation declares one, and from the two table names, sorted, when it does not. The columns are derived the way a hasMany foreign key is — users gives userId — and foreignPivotKey / relatedPivotKey name them where that does not fit. Everything is sorted before it is written down, so both declarations of a mutual relation describe the same table, down to the order of the columns: users.roles and roles.users are one join table, not two that disagree.

A relation whose target is its own table has to name both columns, because userId twice is not a link; it is refused with an error that says so.

withJoinTables(tables) is the schema those tables really have — the declared ones plus a join table per belongsToMany. It is idempotent, so expanding an expanded schema adds nothing.

A model declared for a table a relation derives is refused, with an error naming the relation and the table. Only the DDL would ever read such a model: the pivot verbs of SPEC.md §24 write the two derived columns and nothing else, so a pivot carrying a surrogate key or a joinedAt is a table attach cannot fill, and one carrying exactly the two keys is a second descriptor for a name the adapter already builds. A pivot with columns of its own is a model like any other — declare it with two belongsTo relations and address it as an ordinary table, rather than pointing through at it.

// Refused: `assemora_user_roles` is derived, and `grantedAt` is a column no verb writes.
roles: belongsToMany(() => Role, { through: 'assemora_user_roles' })

pivotAddress(owner, relation, row) is how the data layer addresses the table for one row. It answers with the join table, the two column names and the owner's key value — enough to write the pivot verbs of SPEC.md §24 as ordinary Query AST, with no operation and no adapter method of their own.

const pivot = pivotAddress(User.descriptor, roles, user.toJSON(), Role.descriptor)

// attach
await adapter.execute(
  {
    ...emptyQuery(pivot.table.name, 'insert'),
    data: { [pivot.ownerColumn]: pivot.ownerValue, [pivot.relatedColumn]: roleId },
  },
  { table: pivot.table },
)

// detach
await adapter.execute(
  {
    ...emptyQuery(pivot.table.name, 'delete'),
    where: [
      comparison(pivot.ownerColumn, '=', pivot.ownerValue),
      comparison(pivot.relatedColumn, '=', roleId),
    ],
  },
  { table: pivot.table },
)

A join table has no key of its own — the pair is its identity — so its primaryKey is empty and uniqueTogether carries the constraint. That is the one thing a generator has to read that a model table never sets.

Schema diffing

diffSchema(before, after) says what has to happen for one set of table descriptors to become another (SPEC.md §34). It is pure, it holds no SQL and it knows no dialect: whether a column can still hold what it held is a property of the types, so the answer belongs beside the descriptors every adapter already shares.

// Both sides are descriptors the framework produced: the snapshot of the last
// generated schema, and the model registry as it stands now (ADR-0021).
const diff = diffSchema(snapshot.tables, declaredTables)

if (isDestructive(diff)) {
  for (const change of diff.changes) {
    if (change.destructive) console.warn(describeChange(change)) // "drops column articles.subtitle"
  }
}

Both sides are expanded with withJoinTables first, so a model that gains a belongsToMany gains a table in the diff and one that loses it loses the table — db:generate writes the join table without knowing what a join table is. The expansion is idempotent, so a snapshot that already holds one still compares clean.

SchemaChange is a discriminated union a generator exhausts with a switch and no default case, so a new kind of change is a compile error everywhere it has to be handled. Every change carries before, after or both — enough to write the up and the down migration without reading the descriptors again — and answers the two risk questions isDestructive and mayFailOnExistingRows ask over the whole diff.

The two questions are different ones. isDestructive asks whether applying the diff may lose data no later migration can bring back; mayFailOnExistingRows asks whether a table that already holds rows may refuse it — an add column ... not null, a narrowing that a stored value does not fit, a unique constraint the rows already break. A model default does not answer the second: defaults are applied by the data layer on insert and never reach the schema (ADR-0011), so a required column arrives with nothing to put in the rows that are already there.

introspect() is not a before. A diff is taken against the generated snapshot rather than the live database (ADR-0021), and today it has to be: introspect() reports relations: [] and maps an enum column back to the text PostgreSQL stores it as, so comparing it with the registry reports a foreign key added and a text -> enum change for every relation and every enum column, on every run. Drift against a real database belongs in assemora db:status, and needs an introspection that reads constraints first.

Workspace dependencies

  • @assemora/schema
  • @assemora/core