Skip to content

AST Approach

Because it separates query construction from execution. The core defines what to query, the provider defines how to execute it. This enables multi-database and edge compatibility without API changes.

Your code → Client (QueryBuilder → AST) → Provider (Compiler → SQL) → Database

Classes promise methods that later cause confusion (User.find(), User.delete()). JSORM keeps models as metadata only and leaves queries to jsorm.users.get().

Instance methods mutate state (unpredictable), while pure functions create new instances (deterministic).


include is ambiguous. It doesn’t say what columns or filters the nested relation has. Relations live as nested objects in the query:

// Correct — explicit
select: {
id: true,
name: true,
role: { name: true }
}
// Incorrect — ambiguous
include: { role: true }

It is explicit, typeable, and consistent with select and where.


Relations are properties of the model, not the query. User.hasMany("permission") says “this user has many permissions”, which is a model property. In the query you only specify which relations to include and how.


Model fields are referenced as strings (where: { active: true }). Using imports or symbols loses serialization, diffing, and relational database compatibility.


SQL is an implementation detail of each provider. If you exposed it in the API, each provider would have its own syntax and you’d lose portability between SQLite and PostgreSQL.


Because the core must work in any environment. If it executes queries, opens connections, or generates SQL, it depends on Node, the filesystem, or database drivers. Separating metadata from execution enables pure edge compatibility.


Because each database has a different driver, SQL dialect, and connection manager. A SQLite provider doesn’t need the pg driver or PostgreSQL compiler. Each can have its own dependencies without affecting others.


Because JSORM is a query ORM, not a database admin ORM. Migrations and CLI can use Node, but queries must work where data is used: at the edge, in the browser, in a worker.


Because an application can have multiple databases. The model explicitly indicates which one it belongs to, and the client automatically resolves the correct provider for each model.


Because the database is defined in the config, not the client. defineConfig({ databases: { main: { provider: "..." } } }) already indicates where the model lives.


Because the client does not resolve environment variables. Each string in options is a variable name that the internal provider resolves. This keeps the client pure and edge-compatible.


Because the ORM must know how to translate model and field names to SQL without guessing. naming.fields controls columns, naming.tables controls tables. This prevents inconsistencies between TypeScript code and the actual database.