annotify-jpa
v0.1.0
Published
Spring Data JPA ergonomics for annotify. @Entity, repositories, derived queries, full relationship mapping, pluggable DB drivers.
Maintainers
Readme
annotify-jpa
Spring Data JPA ergonomics on top of
annotify—@Entity,@Repository, derived queries, full relationship mapping, pluggable database drivers.
annotify-jpa adds Spring-style data access to the annotify HTTP framework. Define entities with decorators, write repositories by extending a base class, let the framework translate method names like findByEmailAndActiveTrue into SQL, and plug in any database via a tiny driver interface.
- npm: https://www.npmjs.com/package/annotify-jpa
- GitHub: https://github.com/SanjaySokal/annotify-jpa
- Author: https://www.sanjaysokal.com/
Why annotify-jpa?
annotify is the routing + middleware + templates layer. To persist data, you wire a mysql2 / pg / better-sqlite3 / mongodb driver manually for every controller. annotify-jpa keeps the Spring Data feel: declarative entities, repository inheritance, derived queries, transactions, and full @OneToMany / @ManyToOne / @ManyToMany relationships.
Zero runtime database SDK is bundled — you install the SDK yourself and implement a 5-line JpaDriver adapter (or use one of the example adapters).
Quick start
import { AppBuilder } from 'annotify';
import { enableJpaEntities, enableJpaRepositories } from 'annotify-jpa';
import {
Entity, Id, Column, GeneratedValue,
Repository, RepositoryDecorator,
EntityManager, InMemoryDriver, /* or your own JpaDriver */
} from 'annotify-jpa';
// 1. Define an entity
@Entity()
class User {
@Id() @GeneratedValue('identity') id!: number;
@Column({ length: 120, unique: true }) email!: string;
}
// 2. Define a repository
@RepositoryDecorator(User)
class UserRepository extends Repository<User, number> {
// Derived query — no annotation needed.
findByEmail(email: string) { return super.findOne({ where: { email } }); }
}
// 3. Wire into annotify
const driver = new InMemoryDriver(); // or your own JpaDriver
const em = new EntityManager(driver);
const app = new AppBuilder();
app.set('ddl-auto', 'update'); // 'update' | 'migrate' | 'validate' | 'none'
enableJpaEntities(app, em);
enableJpaRepositories(app, em, [UserRepository]);
app.listen(3000);Decorators
Class-level
@Entity({ table?, schema? })— marks a class as a JPA entity.@Table('app_user')— explicit table-name override.@MappedSuperclass()— parent-class pattern.@Embeddable()— for value-object composition.@RepositoryDecorator(SomeEntity)— marks a class as a repository bound to an entity.
Column-level
@Id()— primary key.@GeneratedValue('auto' | 'identity' | 'sequence' | 'uuid')— generation strategy.@Column({ name?, type?, length?, nullable?, unique?, default?, index? })— column metadata.@Version()— optimistic-locking column.@Enumerated({ type: 'string' | 'ordinal' })— enum storage mode.@Temporal({ type: 'date' | 'time' | 'datetime' | 'timestamp' })— date type.@Lob()— large object (text or blob).
Relationship-level
@ManyToOne(() => Target, { fetch?, optional? })— owning FK.@OneToMany(() => Target, { mappedBy, cascade?, orphanRemoval? })— inverse collection.@OneToOne(() => Target, { mappedBy?, optional? })— single row reference.@ManyToMany(() => Target, { mappedBy?, cascade?, joinTable? })— join-table relation.@JoinColumn({ name?, referencedColumnName?, nullable?, unique? })— owning-side FK.@JoinTable({ name?, joinColumns?, inverseJoinColumns? })— ManyToMany join table.
Repository-level
@Query(value, { native?, modifying? })— explicit SQL/JPQL override.@Modifying()— flag a method as INSERT/UPDATE/DELETE.@Param('email')— bind parameter name.
Transaction
@Transactional()— class- or method-level transaction boundary.
Repositories
Base CRUD (free)
Every Repository<T, ID> automatically exposes:
findById(id)/findOne({ where })/findAll({ orderBy?, limit?, offset? })save(entity)/saveAll(entities)delete(entity)/deleteById(id)count({ where? })/existsById(id)
Derived queries (free, no annotations)
Method names are parsed at boot. Supported keywords:
- Logical:
And,Or - Comparison:
Is,Equals,Between,LessThan,GreaterThan,Like,NotLike,In,NotIn,IsNull,IsNotNull,True,False - Order:
OrderBy<prop>Asc/Desc - Limit:
First<N>,Top<N>,findOne...,findFirst...
Examples:
findByEmail(email: string): Promise<User | null>
findByActiveTrueAndRoleOrderByNameAsc(role: string): Promise<User[]>
countByRole(role: string): Promise<number>
deleteByActiveFalse(): Promise<number>
existsByEmail(email: string): Promise<boolean>
findFirst10ByOrderTotalDesc(): Promise<Order[]>Explicit queries
@Query('SELECT * FROM users WHERE created_at < :since', { native: true })
findStale(@Param('since') since: Date): Promise<User[]>
@Modifying
@Query('UPDATE users SET last_seen = NOW() WHERE id = :id', { native: true })
touchLastSeen(@Param('id') id: number): Promise<void>Drivers
The JpaDriver interface is small. The in-memory driver ships in the package; for real databases, write a 5-line adapter:
import type { JpaDriver } from 'annotify-jpa';
import mysql from 'mysql2/promise';
export function createMysqlDriver(config: mysql.ConnectionOptions): JpaDriver {
const pool = mysql.createPool(config);
return {
dialect: 'mysql',
init: async () => {},
query: async (sql, params) => (await pool.query(sql, params))[0] as any,
queryOne: async (sql, params) => ((await pool.query(sql, params))[0] as any[])[0] ?? null,
exec: async (sql, params) => pool.execute(sql, params),
insert: async (sql, params) => pool.execute(sql, params),
beginTransaction: async () => { /* ... */ },
schema: () => ({ /* ... */ }),
close: () => pool.end(),
};
}See examples/main-sql.ts and examples/main-mongo.ts for full adapters.
Schema management
app.set('ddl-auto', 'update'); // create-if-missing + add-column
app.set('ddl-auto', 'migrate'); // run ./migrations/*.sql in order
app.set('ddl-auto', 'validate'); // throw on missing columns
app.set('ddl-auto', 'none'); // do nothingFor migrate mode, put SQL files in ./migrations/:
-- migrations/001-create-users.sql
CREATE TABLE IF NOT EXISTS `users` (...);The runner records each applied filename in a _migrations table.
Transactions
@Transactional()
class UserService {
transfer(from: User, to: User, amount: number) {
// any throw rolls back; returning commits.
}
}The decorator wraps each method in em.transaction(fn). If no EntityManager is installed, the wrapper is a pass-through (so the same class can run offline against InMemoryDriver).
Cache integration (with annotify-redis)
@Cacheable from annotify-redis works on repository methods — the @Cacheable decorator writes the same side-channel the dispatcher reads:
import { Cacheable } from 'annotify-redis';
@RepositoryDecorator(User)
class UserRepository extends Repository<User, number> {
@Cacheable('users:byId', { ttl: 60 })
findById(id: number) { return super.findById(id); }
}In app.ts:
import { enableCaching } from 'annotify-redis';
enableCaching(app, cache); // must run before enableJpaRepositories
enableJpaEntities(app, em);
enableJpaRepositories(app, em, [UserRepository]);Verification
npm install --save-dev typescript @types/node
npm run build
npm run smoke # offline smoke (in-memory driver, 12 checks)For real databases: docker run -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root mysql:8, then node dist/examples/main-sql.js.
License
MIT — see LICENSE.
