@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_QUERYNothing 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 })andQuery.scopeare validated byparseQueryand then read by no driver, so?scope=activereturns the unscoped collection with a 200. Express the fragment in adoBeforeReadhook instead. withDeletedis not gated. §14.2 says including soft-deleted rows "requires an explicit permission";parseQueryacceptswithDeleted=truefrom any client. Drop the key before parsing, or overwrite it in a read hook, until a permission check exists.- Ownership scoping on
restore.restorecalls the driver directly, so no read hook narrows it. - Optimistic locking.
ModelDescriptor.versionedandOptimisticLockErrorexist; no driver reads or throws either. - History and trash (§14.2, an optional plugin) and the
database.maxQueryLimitceiling — a policy states its ownmaxLimitand nothing reads the config value. ModelDescriptor.timestampsis 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.
