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

@nage-api/data

v1.0.0-beta.4

Published

Engine-agnostic data layer for @nage-api — repository contract, typed query DSL, ModelService, migrations

Readme

@nage-api/data

The engine-agnostic data layer (PLAN.md §14): one contract, pluggable drivers, one engine per project.

The legacy framework compiled both SQL and Mongo into every application and chose between them at runtime with APP_ENGINE — so a "SQL" deployment still booted a Mongo connection it never used. Here the driver is a scaffold-time choice: application code depends on RepositoryPort, and exactly one driver is installed. @nage-api/data-sql is the driver that exists today; the Mongo driver §8 names is not written yet.

What it provides

| Area | Export | Notes | | ---------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------- | | Query DSL | defineQueryPolicy, parseQuery, parseWhere, parseSort | Allow-listed fields and operators, enforced maxLimit (§12) | | Audit | stampCreate, stampUpdate, stampSoftDelete, stampRestore | Who and when, taken from the request context (§14.2) | | Service | ModelService<TEntity> | The retained lifecycle hooks, typed; ownership scoping via scopeToOwner | | In-memory driver | MemoryRepository, MemoryUnitOfWork | A real implementation, used as a test double (§19) | | Migrations | MigrationRunner, SeedRunner | Versioned and recorded; replaces synchronize auto-sync | | Health | checkDataSourceHealth | Feeds /health/ready (§21) | | Conformance | describeRepositoryConformance | The suite every driver must pass |

The query DSL

The DSL survives — it is what made the legacy framework productive — but a model now declares what a client may reach:

import { defineQueryPolicy, parseQuery, type RawQuery } from '@nage-api/data';
import type { Query } from '@nage-api/contracts';

interface Product {
  readonly id: number;
  name: string;
  price: number;
  archived: boolean;
  created_at: string;
}

const productPolicy = defineQueryPolicy<Product>({
  filterable: ['name', 'price', 'archived'],
  sortable: ['name', 'price', 'created_at'],
  selectable: ['id', 'name', 'price'],
  searchable: ['name'],
  maxLimit: 100,
});

/** `request.query`, as the platform hands it over. Nothing has validated it yet. */
declare const request: { readonly query: RawQuery };

const query: Query<Product> = parseQuery(request.query, productPolicy); // throws INVALID_QUERY

Nothing is filterable by default: a model with no policy exposes no fields, so forgetting to write one fails closed. limit: -1 has no representation, and a page larger than the ceiling is refused rather than clamped — silently clamping would let a client believe it received the 100 000 rows it asked for. Mongo-style $-operators are rejected outright; only names in the operator allow-list are ever emitted to a driver.

parseQuery is the allow-list, and a repository's own policy is not a second one: a where clause built in server code reaches the driver whatever the policy lists. Untrusted input goes through parseQuery; nothing else does.

ModelService

import type { BaseEntity, Job } from '@nage-api/contracts';
import { ModelService } from '@nage-api/data';

interface Note extends BaseEntity {
  readonly id: number;
  user_id: string;
  body: string;
}

class NoteService extends ModelService<Note> {
  // `audit` is the application's own collaborator; `ModelService` supplies the
  // lifecycle and the transaction, not the side effect.
  private readonly audit!: { record(event: string, entity: Note | undefined): Promise<void> };

  protected override doBeforeRead(job: Job<Note>): void {
    this.scopeToOwner(job, 'user_id'); // typed `job.where.user_id = job.owner.id`
  }

  protected override async doAfterCreate(job: Job<Note>): Promise<void> {
    // Runs inside the transaction, so a failure here rolls the insert back.
    await this.audit.record('note.created', job.record);
  }
}

Hooks run inside the unit of work, so a failing doAfterCreate rolls the create back. Ownership scoping applies to reads by id as well as to lists, and update and delete load the record through the same path first, so a caller cannot reach another owner's record by guessing an id. restore is the exception: it runs no read hook, so a route exposing it needs its own check until that is fixed (see "Not yet implemented").

Conformance

Driver parity is this layer's risk, so it is a gate rather than a promise: every driver runs the same suite.

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { MemoryRepository, MemoryUnitOfWork, describeRepositoryConformance } from '@nage-api/data';
import type { ConformanceWidget } from '@nage-api/data';

describeRepositoryConformance(
  { describe, it, beforeEach, afterEach, expect },
  {
    name: 'memory',
    // A fresh, empty repository per test; the suite seeds what each case needs.
    setup: () => {
      const unitOfWork = new MemoryUnitOfWork();
      const repository = new MemoryRepository<ConformanceWidget>('Widget', { unitOfWork });

      return Promise.resolve({ repository, unitOfWork });
    },
  },
);

The test API is injected, so the published package carries no test-framework dependency and any runner can drive it. A driver that genuinely lacks a capability declares it in unsupported — an escape hatch a reviewer can see.

The suite covers creates and audit stamping, pagination and counts, the operator set, sorting, projection, soft delete and restore, hard delete, bulk operations, cursor pagination, search, and transactions — including that a failure rolls back every write in the unit and that the original error propagates rather than being wrapped.

Not yet implemented

  • @nage-api/data-mongo. §8 and §14.1 both name it; only the SQL driver exists. Nothing in this package assumes SQL, so the second driver is work rather than a redesign — but "one engine per project" currently has one engine to choose.
  • Named scopes. defineQueryPolicy({ scopes }) and Query.scope are validated by parseQuery and then read by no driver, so ?scope=active returns the unscoped collection with a 200. Express the fragment in a doBeforeRead hook instead.
  • withDeleted is not gated. §14.2 says including soft-deleted rows "requires an explicit permission"; parseQuery accepts withDeleted=true from any client. Drop the key before parsing, or overwrite it in a read hook, until a permission check exists.
  • Ownership scoping on restore. restore calls the driver directly, so no read hook narrows it.
  • Optimistic locking. ModelDescriptor.versioned and OptimisticLockError exist; no driver reads or throws either.
  • History and trash (§14.2, an optional plugin) and the database.maxQueryLimit ceiling — a policy states its own maxLimit and nothing reads the config value.
  • ModelDescriptor.timestamps is accepted and ignored: audit columns are always stamped.

docs/packages/data.md is the longer guide: every policy option and its default, the mistakes worth knowing about, and how to test against this package.