persistence-js: the future persistence layer of Exygen applications

Every Exygen application needs, sooner or later, the same thing: describe a data model, persist it to a database, evolve it without breaking production, and query it cleanly from code. Until now, each application reinvented its own data access layer. persistence-js puts an end to that.

The problem

In TypeScript/Node, you often have to choose between two bad options: a raw SQL layer (fast, but where you hand-write every INSERT/UPDATE and recompute relations everywhere), or an ActiveRecord-style ORM (entities that inherit from a base class, implicit change tracking, and switching database engines that is nearly impossible in practice).

persistence-js aims for the middle ground: a JDO/JPA-style persistence engine, where the data model is declared once, in JSON — and everything else (TypeScript types, typed queries, SQL schema, search index) follows from it.

The model, as data

You describe your entities once, as JSON:

 1// model.json
 2{
 3  "kind": "MODEL", "schema": "blog",
 4  "entities": [
 5    {
 6      "kind": "ENTITY", "id": "e-author", "name": "Author",
 7      "fields": [
 8        {"kind": "FIELD", "id": "f-a-id", "name": "id", "type": "INTEGER", "primaryKey": true, "autoIncrement": true},
 9        {"kind": "FIELD", "id": "f-a-firstname", "name": "firstName", "type": "STRING", "allowNull": false},
10        {"kind": "FIELD", "id": "f-a-email", "name": "email", "type": "STRING", "unique": true}
11      ],
12      "relations": [
13        {"kind": "RELATION", "id": "r-author-posts", "name": "posts", "source": "Author", "target": "Post",
14         "type": "Composition", "cardinality": "Many", "foreignKey": "author_id"}
15      ]
16    }
17  ]
18}

From this file, TypeScript interfaces and typed query accessors are generated, never hand-written:

1// generated: src/generated/entities/Author.ts
2export interface Author extends PersistenceCapable {
3  firstName: string;
4  email: string;
5  posts: Post[];
6}
7export function makeAuthor(id: number, firstName: string, email: string): Author { /* ... */ }

CRUD without a single line of SQL

 1import {PersistenceManagerFactoryImpl} from '@exygen/persistence-js';
 2import {makeAuthor} from './generated/entities';
 3
 4const factory = await PersistenceManagerFactoryImpl.create({
 5  driver: {type: 'postgresql', host: 'localhost', database: 'blog', user: 'user', password: 'password'},
 6  models: './model.json',
 7  schemaInstall: 'align', // creates/aligns the schema with the model
 8});
 9
10const manager = await factory.createManager();
11
12const tx = manager.beginTransaction();
13const author = await manager.create(makeAuthor(0, 'Alice', 'alice@example.com'));
14await tx.commit(); // a single ordered, atomic SQL batch
15
16author.email = 'alice@exygen.fr'; // just mutate a plain JS object...
17await manager.update(author);      // ...change tracking does the rest
18
19await manager.close();
20await factory.closeAll();

No .save(), no base class to inherit from, no manual before/after diff: mutating a field on a loaded object is enough, the engine knows an UPDATE will be needed at the next commit().

Typed queries, not strings

1const posts = await manager.find('Post', {
2  where: p => and(p.active.isTrue(), p.title.like('Hello%')),
3  sortAsc: p => p.title,
4  take: 10,
5});

Autocompletion works on the model's actual fields and relations — no more misspelled column name discovered in production.

Why it is becoming our common foundation

Several reasons drove this choice for all Exygen back-ends:

  • One model, several consumers — the same JSON MetaModel drives TypeScript types, queries, the SQL schema and, optionally, search indexing. Change a field once, everything else follows.
  • Predictable writes — automatic change tracking + explicit commit(): what will be written, and when, never depends on a hidden mechanism.
  • Safe schema evolutionupdate mode (additive, safe in production) or align mode (strict, for dev/CI), with diff and dry-run.
  • No database vendor lock-in — PostgreSQL, MariaDB, or MySQL: switching engines is a configuration change, not a rewrite.
  • Pay only for what you use — cache, full-text search, file storage, Express plugin: each building block is a separate @exygen/* package, installed only when needed.
  • Multi-tenant by design — "layers" (tenant, soft delete, fiscal year...) are declared on the model once, and apply automatically to every query, without rewriting them application by application.

By unifying the persistence layer on a single shared library, every new Exygen application starts with reliable, tested, already documented data access — instead of starting from scratch on every project.

Going further

1npm install @exygen/persistence-js
2npm install pg   # or mariadb, depending on the target engine

persistence-js and its modules (jmetadata, jdriver, jcache, tslogger, search-index, file-storage, express, query-assist) are published independently under @exygen/* — you only install what you need.

Translations: