@cleverbrush/orm 0.0.0-beta-20260424142030
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +349 -0
- package/dist/change-tracker.d.ts +118 -0
- package/dist/dbcontext.d.ts +130 -0
- package/dist/dbset.d.ts +181 -0
- package/dist/entity.d.ts +2 -0
- package/dist/errors.d.ts +55 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/result-types.d.ts +154 -0
- package/dist/save-graph.d.ts +27 -0
- package/dist/variant-write.d.ts +38 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# `@cleverbrush/orm`
|
|
2
|
+
|
|
3
|
+
EF-Core-like typed ORM layer on top of [`@cleverbrush/knex-schema`](../knex-schema).
|
|
4
|
+
|
|
5
|
+
- Define entities with `defineEntity(schema)` and declare relations via
|
|
6
|
+
`.hasOne()` / `.hasMany()` / `.belongsTo()` / `.belongsToMany()`.
|
|
7
|
+
- Group entities into a typed context with `createDb(knex, { todos, users })`.
|
|
8
|
+
- Access `db.todos`, `db.users` as fully-typed `DbSet<TEntity>` instances.
|
|
9
|
+
- Eager-load related entities with `.include(t => t.author)`.
|
|
10
|
+
- Use `{ tracking: true }` for an identity map + change-tracking context.
|
|
11
|
+
- Model inheritance with STI (single-table) and CTI (class-table) variants.
|
|
12
|
+
- Manage schema migrations with [`@cleverbrush/orm-cli`](../orm-cli).
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm install @cleverbrush/orm
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`knex` is a peer dependency (install it alongside `@cleverbrush/orm`). `@cleverbrush/knex-schema` is a direct dependency and is installed automatically.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
### 1. Define a schema and entity
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { number, object, string, defineEntity } from '@cleverbrush/orm';
|
|
32
|
+
|
|
33
|
+
const UserSchema = object({
|
|
34
|
+
id: number().primaryKey(),
|
|
35
|
+
email: string().hasColumnName('email_address'),
|
|
36
|
+
name: string(),
|
|
37
|
+
}).hasTableName('users');
|
|
38
|
+
|
|
39
|
+
export const UserEntity = defineEntity(UserSchema);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 2. Create a `DbContext`
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import knex from 'knex';
|
|
46
|
+
import { createDb } from '@cleverbrush/orm';
|
|
47
|
+
import { UserEntity } from './schemas.js';
|
|
48
|
+
|
|
49
|
+
const knexClient = knex({ client: 'pg', connection: process.env.DATABASE_URL });
|
|
50
|
+
|
|
51
|
+
const db = createDb(knexClient, { users: UserEntity });
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 3. Query and mutate
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// Find by PK
|
|
58
|
+
const alice = await db.users.find(1);
|
|
59
|
+
|
|
60
|
+
// Find with WHERE clause
|
|
61
|
+
const user = await db.users.where(t => t.email, 'alice@example.com').first();
|
|
62
|
+
|
|
63
|
+
// Insert
|
|
64
|
+
const created = await db.users.save({ email: 'bob@example.com', name: 'Bob' });
|
|
65
|
+
|
|
66
|
+
// Update (PK present → UPDATE)
|
|
67
|
+
const updated = await db.users.save({ id: 1, email: 'alice@example.com', name: 'Alice' });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## `DbSet<TEntity>` API
|
|
73
|
+
|
|
74
|
+
| Method | Description |
|
|
75
|
+
|--------|-------------|
|
|
76
|
+
| `.find(pk)` | Find one row by PK; returns `undefined` when not found |
|
|
77
|
+
| `.findOrFail(pk)` | Like `.find`, but throws `EntityNotFoundError` when not found |
|
|
78
|
+
| `.findMany([pk1, pk2, …])` | Fetch multiple rows by PK in one query |
|
|
79
|
+
| `.all()` | Alias for `.execute()` — returns all rows |
|
|
80
|
+
| `.first()` | Returns the first matching row or `undefined` |
|
|
81
|
+
| `.where(col, value)` | Adds a `WHERE` predicate (chainable) |
|
|
82
|
+
| `.include(t => t.rel)` | Eager-loads a relation (chainable) |
|
|
83
|
+
| `.save(graph)` | Insert or update a row graph (transactional) |
|
|
84
|
+
| `.ofVariant(key)` | Return a typed `VariantDbSet` scoped to a polymorphic variant |
|
|
85
|
+
| `.query()` | Returns the underlying `EntityQuery` for advanced querying |
|
|
86
|
+
| `.withTransaction(trx)` | Returns a new `DbSet` bound to an existing transaction |
|
|
87
|
+
|
|
88
|
+
Any method from the underlying `SchemaQueryBuilder` (e.g. `.execute()`,
|
|
89
|
+
`.where()`, `.orderBy()`, `.pluck()`, `.count()`) is also available and
|
|
90
|
+
fully typed.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Relations
|
|
95
|
+
|
|
96
|
+
Declare relations on the entity using fluent builders. Relations are optional
|
|
97
|
+
by default (omit from the save-graph to skip them).
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const TodoSchema = object({
|
|
101
|
+
id: number().primaryKey(),
|
|
102
|
+
title: string(),
|
|
103
|
+
userId: number().hasColumnName('user_id'),
|
|
104
|
+
author: object({ id: number().primaryKey(), name: string() })
|
|
105
|
+
.hasTableName('users').optional(),
|
|
106
|
+
}).hasTableName('todos');
|
|
107
|
+
|
|
108
|
+
const TodoEntity = defineEntity(TodoSchema)
|
|
109
|
+
.belongsTo(t => t.author, 'userId'); // FK is on todos.user_id → users.id
|
|
110
|
+
|
|
111
|
+
const UserEntity = defineEntity(UserSchema)
|
|
112
|
+
.hasMany(t => t.todos, TodoEntity, 'userId'); // FK on todos.user_id
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Eager-loading
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const todo = await db.todos
|
|
119
|
+
.where(t => t.id, 42)
|
|
120
|
+
.include(t => t.author)
|
|
121
|
+
.first();
|
|
122
|
+
|
|
123
|
+
console.log(todo?.author?.name); // fully typed
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Saving with relations
|
|
127
|
+
|
|
128
|
+
`db.todos.save(graph)` traverses the whole object graph and persists every
|
|
129
|
+
level in the correct FK order inside a single transaction.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// Create a new user and a new todo in one call.
|
|
133
|
+
const result = await db.users.save({
|
|
134
|
+
name: 'Alice',
|
|
135
|
+
todos: [
|
|
136
|
+
{ title: 'Buy milk', completed: false, userId: 0 },
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Topology rules:
|
|
142
|
+
- `belongsTo` parents are inserted first; their PK feeds the child FK.
|
|
143
|
+
- The root entity is then written.
|
|
144
|
+
- `hasOne` / `hasMany` children inherit the root PK into their FK.
|
|
145
|
+
- `belongsToMany` children may be new objects (inserted + pivot) or bare
|
|
146
|
+
`{ pk: value }` references (pivot only).
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Change-tracking context
|
|
151
|
+
|
|
152
|
+
Pass `{ tracking: true }` to `createDb` to get a `TrackedDbContext`. The
|
|
153
|
+
context maintains an **identity map** (same PK → same object reference) and
|
|
154
|
+
tracks every mutation automatically.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const db = createDb(knex, { users: UserEntity }, { tracking: true });
|
|
158
|
+
|
|
159
|
+
// Load entity — it's now in the identity map.
|
|
160
|
+
const user = await db.users.find(1);
|
|
161
|
+
|
|
162
|
+
// Mutate normally.
|
|
163
|
+
user.name = 'Updated';
|
|
164
|
+
|
|
165
|
+
// Flush all dirty entries in a single transaction.
|
|
166
|
+
const { inserted, updated, deleted } = await db.saveChanges();
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### `TrackedDbContext` API
|
|
170
|
+
|
|
171
|
+
| Method / property | Description |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `.attach(key, entity)` | Register an existing entity as `Unchanged` |
|
|
174
|
+
| `.entry(entity)` | Return the `EntityEntry` for a tracked entity |
|
|
175
|
+
| `.entry(entity).state` | `'Added' \| 'Modified' \| 'Unchanged' \| 'Deleted'` |
|
|
176
|
+
| `.entry(entity).isModified(field?)` | Check if a specific (or any) field changed |
|
|
177
|
+
| `.entry(entity).reset()` | Revert the entity to its last snapshot |
|
|
178
|
+
| `.detach(entity)` | Remove an entity from the tracker |
|
|
179
|
+
| `.remove(entity)` | Mark a tracked entity as `Deleted` |
|
|
180
|
+
| `.saveChanges()` | Flush all pending changes to the DB |
|
|
181
|
+
| `.discardChanges()` | Roll back all in-memory changes |
|
|
182
|
+
| `.reload(entity)` | Re-fetch the entity from the DB and refresh its snapshot |
|
|
183
|
+
| `.onSavingChanges(hook)` | Register a pre-flush callback (e.g. for audit fields) |
|
|
184
|
+
| `[Symbol.asyncDispose]()` | Usable in `await using` blocks; throws on pending changes |
|
|
185
|
+
|
|
186
|
+
### Row versioning
|
|
187
|
+
|
|
188
|
+
Mark any numeric or timestamp column as a row version:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
const OrderSchema = object({
|
|
192
|
+
id: number().primaryKey(),
|
|
193
|
+
status: string(),
|
|
194
|
+
version: number().rowVersion(), // auto-incremented by the ORM on every UPDATE
|
|
195
|
+
}).hasTableName('orders');
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`saveChanges()` appends `AND version = <snapshot>` to every `UPDATE` and throws
|
|
199
|
+
`ConcurrencyError` if `rowCount === 0`.
|
|
200
|
+
|
|
201
|
+
### `await using` integration
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
async function updateUser(userId: number) {
|
|
205
|
+
await using db = createDb(knex, { users: UserEntity }, { tracking: true });
|
|
206
|
+
const user = await db.users.find(userId);
|
|
207
|
+
if (!user) return;
|
|
208
|
+
user.name = 'Updated';
|
|
209
|
+
await db.saveChanges();
|
|
210
|
+
} // Symbol.asyncDispose fires here; throws if changes are still pending
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Polymorphic entities (STI / CTI)
|
|
216
|
+
|
|
217
|
+
### Single-Table Inheritance (STI)
|
|
218
|
+
|
|
219
|
+
All variants share one table; a discriminator column identifies the type.
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
const ActivityBase = object({
|
|
223
|
+
id: number().primaryKey(),
|
|
224
|
+
type: string(),
|
|
225
|
+
todoId: number().hasColumnName('todo_id'),
|
|
226
|
+
}).hasTableName('activities');
|
|
227
|
+
|
|
228
|
+
const ActivityEntity = defineEntity(ActivityBase)
|
|
229
|
+
.discriminator('type')
|
|
230
|
+
.stiVariant('assigned', object({
|
|
231
|
+
type: string('assigned'),
|
|
232
|
+
assigneeId: number().hasColumnName('assignee_id').optional(),
|
|
233
|
+
}))
|
|
234
|
+
.stiVariant('commented', object({
|
|
235
|
+
type: string('commented'),
|
|
236
|
+
body: string().optional(),
|
|
237
|
+
}));
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Class-Table Inheritance (CTI)
|
|
241
|
+
|
|
242
|
+
The base row is in one table; each variant has its own extension table.
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
const AssignedExtras = defineEntity(
|
|
246
|
+
object({
|
|
247
|
+
activityId: number().hasColumnName('activity_id'),
|
|
248
|
+
assigneeId: number().hasColumnName('assignee_id'),
|
|
249
|
+
}).hasTableName('assigned_activities')
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const ActivityEntity = defineEntity(ActivityBase)
|
|
253
|
+
.discriminator('type')
|
|
254
|
+
.ctiVariant('assigned', AssignedExtras, t => t.activityId);
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Querying variants
|
|
258
|
+
|
|
259
|
+
Call `db.set.ofVariant('key')` to obtain a **`VariantDbSet`** — a typed view
|
|
260
|
+
scoped to that variant, analogous to EF Core's `Set<DerivedType>()`. All
|
|
261
|
+
reads are pre-filtered by the discriminator; writes use the correct STI / CTI
|
|
262
|
+
logic automatically.
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
// Insert a new variant row (discriminator is set automatically)
|
|
266
|
+
const activity = await db.activities.ofVariant('assigned').insert({
|
|
267
|
+
todoId: 42,
|
|
268
|
+
assigneeId: 9,
|
|
269
|
+
});
|
|
270
|
+
// activity.type === 'assigned'
|
|
271
|
+
// activity.assigneeId === 9
|
|
272
|
+
|
|
273
|
+
// Find a single variant by PK
|
|
274
|
+
const found = await db.activities.ofVariant('assigned').find(activityId);
|
|
275
|
+
|
|
276
|
+
// Update matching rows (chain .where() before .update())
|
|
277
|
+
await db.activities.ofVariant('assigned').where(t => t.id, 3).update({ assigneeId: 99 });
|
|
278
|
+
|
|
279
|
+
// Delete matching rows
|
|
280
|
+
await db.activities.ofVariant('assigned').where(t => t.id, 3).delete();
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### `VariantDbSet<TEntity, K>` API
|
|
284
|
+
|
|
285
|
+
| Method | Description |
|
|
286
|
+
|--------|-------------|
|
|
287
|
+
| `.insert(payload)` | Insert a new variant row; discriminator is set automatically |
|
|
288
|
+
| `.update(patch)` | Update variant columns for rows matched by the current `WHERE` clause |
|
|
289
|
+
| `.delete()` | Delete rows matched by the current `WHERE` clause (CTI: atomic) |
|
|
290
|
+
| `.find(pk)` | Find a single variant row by PK; `undefined` if not found |
|
|
291
|
+
| `.findOrFail(pk)` | Like `.find`, but throws `EntityNotFoundError` |
|
|
292
|
+
| `.findMany([pk…])` | Fetch multiple variant rows by PK in one query |
|
|
293
|
+
| `.where(col, value)` | Adds a `WHERE` predicate (chainable; returns `VariantDbSet`) |
|
|
294
|
+
| `.include(t => t.rel)` | Eager-loads a relation (chainable; returns `VariantDbSet`) |
|
|
295
|
+
| `.withTransaction(trx)` | Returns a new `VariantDbSet` bound to an existing transaction |
|
|
296
|
+
|
|
297
|
+
Calling `.insert()` / `.update()` / `.delete()` directly on the polymorphic
|
|
298
|
+
base `DbSet` (without `ofVariant`) throws a runtime error — use `ofVariant`
|
|
299
|
+
for all writes on polymorphic entities.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Transactions
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
// One-off transaction (non-tracking context)
|
|
307
|
+
await db.transaction(async trx => {
|
|
308
|
+
const user = await trx.users.save({ name: 'Alice' });
|
|
309
|
+
await trx.todos.save({ title: 'Buy milk', userId: user.id });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Wrap existing transaction
|
|
313
|
+
const user = await db.users.withTransaction(existingTrx).save({ name: 'Alice' });
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Error classes
|
|
319
|
+
|
|
320
|
+
| Class | Thrown when |
|
|
321
|
+
|-------|-------------|
|
|
322
|
+
| `EntityNotFoundError` | `findOrFail` can't locate the requested PK |
|
|
323
|
+
| `ConcurrencyError` | `saveChanges` UPDATE/DELETE hits a row-version mismatch |
|
|
324
|
+
| `InvariantViolationError` | PK or discriminator column mutated on a tracked entity |
|
|
325
|
+
| `PendingChangesError` | `[Symbol.asyncDispose]` fires with unsaved changes |
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Schema migrations
|
|
330
|
+
|
|
331
|
+
Use [`@cleverbrush/orm-cli`](../orm-cli) to generate and apply migration files
|
|
332
|
+
from your entity definitions:
|
|
333
|
+
|
|
334
|
+
```sh
|
|
335
|
+
# Diff schema vs DB → emit a TypeScript migration file
|
|
336
|
+
npx cb-orm migrate generate add_users_table
|
|
337
|
+
|
|
338
|
+
# Apply pending migrations
|
|
339
|
+
npx cb-orm migrate run
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## See also
|
|
345
|
+
|
|
346
|
+
- [`@cleverbrush/knex-schema`](../knex-schema) — the underlying schema DSL and
|
|
347
|
+
query builder
|
|
348
|
+
- [`@cleverbrush/orm-cli`](../orm-cli) — migration CLI tool
|
|
349
|
+
- [API reference](https://cleverbrush.github.io/framework/api-docs/latest)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
/**
|
|
3
|
+
* Entry state in the identity map. Mirrors EF Core's EntityState.
|
|
4
|
+
* @public
|
|
5
|
+
*/
|
|
6
|
+
export type EntryState = 'Added' | 'Unchanged' | 'Modified' | 'Deleted';
|
|
7
|
+
/**
|
|
8
|
+
* Public view of a tracked entry, exposed via `db.entry(entity)`.
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
export interface EntityEntry<T extends object> {
|
|
13
|
+
/** Current state of the entry. */
|
|
14
|
+
readonly state: EntryState;
|
|
15
|
+
/** Snapshot of the values at the time the entity was last loaded/saved. */
|
|
16
|
+
readonly originalValues: Readonly<T>;
|
|
17
|
+
/** Live object (same reference as the tracked entity). */
|
|
18
|
+
readonly currentValues: T;
|
|
19
|
+
/**
|
|
20
|
+
* Returns `true` when a specific field has changed since the last
|
|
21
|
+
* snapshot, or `true` when any field has changed when called without
|
|
22
|
+
* arguments.
|
|
23
|
+
*/
|
|
24
|
+
isModified(field?: keyof T): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Reset all changes to the snapshot values and transition state back
|
|
27
|
+
* to `'Unchanged'` (or `'Added'` if the entity was never persisted).
|
|
28
|
+
*/
|
|
29
|
+
reset(): void;
|
|
30
|
+
}
|
|
31
|
+
/** @internal Per-entity-set configuration needed by the tracker. */
|
|
32
|
+
export interface EntitySetConfig {
|
|
33
|
+
/** The entity schema (used for PK resolution). */
|
|
34
|
+
schema: any;
|
|
35
|
+
/** Logical entity-set name (e.g. `'todos'`). */
|
|
36
|
+
entitySetKey: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Pre-save hook callback signature registered via
|
|
40
|
+
* `TrackedDbContext.onSavingChanges(hook)`.
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
export type SavingChangesHook = (entry: EntityEntry<object>) => void | Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* The change-tracking core used by tracked `DbContext` instances.
|
|
46
|
+
*
|
|
47
|
+
* Consumers interact with this via the `DbContext` API methods; the
|
|
48
|
+
* `ChangeTracker` itself is not exported as part of the public API.
|
|
49
|
+
*
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
52
|
+
export declare class ChangeTracker {
|
|
53
|
+
#private;
|
|
54
|
+
/** Register an entity set so the tracker knows its schema. */
|
|
55
|
+
registerEntitySet(config: EntitySetConfig): void;
|
|
56
|
+
/** Register a pre-save hook. */
|
|
57
|
+
onSavingChanges(hook: SavingChangesHook): void;
|
|
58
|
+
/**
|
|
59
|
+
* Attach an entity to the tracker under the given entity-set key.
|
|
60
|
+
*
|
|
61
|
+
* If an entry with the same PK already exists, returns the EXISTING
|
|
62
|
+
* tracked object (identity-map guarantee). If the entity is already the
|
|
63
|
+
* same object, just updates its snapshot.
|
|
64
|
+
*
|
|
65
|
+
* @returns The tracked entity (same ref when already in the map).
|
|
66
|
+
*/
|
|
67
|
+
attach<T extends object>(entitySetKey: string, entity: T, state?: EntryState): T;
|
|
68
|
+
/** Detach an entity from tracking. */
|
|
69
|
+
detach(entity: object): void;
|
|
70
|
+
/**
|
|
71
|
+
* Mark an entity for deletion on the next `saveChanges()` call.
|
|
72
|
+
* The entity must already be tracked.
|
|
73
|
+
*/
|
|
74
|
+
remove(entity: object): void;
|
|
75
|
+
/** Return the public `EntityEntry` view for a tracked entity. */
|
|
76
|
+
entry<T extends object>(entity: T): EntityEntry<T>;
|
|
77
|
+
/**
|
|
78
|
+
* Discard all pending changes: reset all Modified entries to their
|
|
79
|
+
* snapshots, remove Added entries from the tracker, restore Deleted
|
|
80
|
+
* entries to Unchanged.
|
|
81
|
+
*/
|
|
82
|
+
discardChanges(): void;
|
|
83
|
+
/**
|
|
84
|
+
* Refresh a tracked entity from the DB, replacing its current values
|
|
85
|
+
* and snapshot with the freshly-loaded row.
|
|
86
|
+
*/
|
|
87
|
+
reload(entity: object, knex: Knex): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* Returns `true` when there are any pending changes (Added / Modified /
|
|
90
|
+
* Deleted entries, or silently mutated Unchanged entries).
|
|
91
|
+
*/
|
|
92
|
+
hasPendingChanges(): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Flush all pending changes to the database within a single transaction.
|
|
95
|
+
*
|
|
96
|
+
* Returns the number of rows inserted, updated, and deleted.
|
|
97
|
+
*
|
|
98
|
+
* Invariants checked:
|
|
99
|
+
* - PK columns must not have changed since the snapshot (throws
|
|
100
|
+
* `InvariantViolationError`).
|
|
101
|
+
* - Discriminator columns must not have changed (throws
|
|
102
|
+
* `InvariantViolationError`).
|
|
103
|
+
* - For `rowVersion` columns: WHERE clause enforces the snapshot value;
|
|
104
|
+
* zero affected rows throws `ConcurrencyError`.
|
|
105
|
+
*/
|
|
106
|
+
saveChanges(knex: Knex): Promise<{
|
|
107
|
+
inserted: number;
|
|
108
|
+
updated: number;
|
|
109
|
+
deleted: number;
|
|
110
|
+
}>;
|
|
111
|
+
/**
|
|
112
|
+
* Generate a summary of pending changes for error messages.
|
|
113
|
+
* @internal
|
|
114
|
+
*/
|
|
115
|
+
pendingSummary(): string;
|
|
116
|
+
/** Clear the entire identity map (used on dispose). */
|
|
117
|
+
clear(): void;
|
|
118
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Entity } from '@cleverbrush/knex-schema';
|
|
2
|
+
import type { Knex } from 'knex';
|
|
3
|
+
import { type EntityEntry, type SavingChangesHook } from './change-tracker.js';
|
|
4
|
+
import { type DbSet } from './dbset.js';
|
|
5
|
+
/**
|
|
6
|
+
* The map of entity name → entity definition passed to {@link createDb}.
|
|
7
|
+
*
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export type EntityMap = Record<string, Entity<any, any, any>>;
|
|
11
|
+
/**
|
|
12
|
+
* The shape of the context object returned by {@link createDb}: every
|
|
13
|
+
* registered entity becomes a typed `DbSet` property.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
export type DbContext<TMap extends EntityMap> = {
|
|
18
|
+
readonly [K in keyof TMap]: DbSet<TMap[K]>;
|
|
19
|
+
} & {
|
|
20
|
+
/**
|
|
21
|
+
* The underlying Knex instance the context was constructed with.
|
|
22
|
+
*/
|
|
23
|
+
readonly knex: Knex;
|
|
24
|
+
/**
|
|
25
|
+
* Run `callback` inside a Knex transaction. The callback receives a new
|
|
26
|
+
* `DbContext` whose `DbSet`s are bound to the transaction.
|
|
27
|
+
*/
|
|
28
|
+
transaction<T>(callback: (db: DbContext<TMap>) => Promise<T>): Promise<T>;
|
|
29
|
+
/**
|
|
30
|
+
* Return a new `DbContext` whose `DbSet`s are bound to `trx`. Useful
|
|
31
|
+
* when you already have a Knex transaction in scope.
|
|
32
|
+
*/
|
|
33
|
+
withTransaction(trx: Knex.Transaction): DbContext<TMap>;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Extended context returned when `{ tracking: true }` is passed to
|
|
37
|
+
* {@link createDb}. Adds identity-map tracking, unit-of-work `saveChanges`,
|
|
38
|
+
* and `[Symbol.asyncDispose]` support for `await using` blocks.
|
|
39
|
+
*
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
export type TrackedDbContext<TMap extends EntityMap> = DbContext<TMap> & {
|
|
43
|
+
/**
|
|
44
|
+
* Attach an entity to the identity map so its changes will be tracked.
|
|
45
|
+
* If an entity with the same primary key is already tracked, the existing
|
|
46
|
+
* tracked object is returned (identity-map guarantee).
|
|
47
|
+
*
|
|
48
|
+
* @param entitySetKey — the property name used when registering the entity
|
|
49
|
+
* (e.g. `'todos'` for `createDb(knex, { todos: TodoEntity })`).
|
|
50
|
+
*/
|
|
51
|
+
attach<T extends object>(entitySetKey: string, entity: T): T;
|
|
52
|
+
/**
|
|
53
|
+
* Remove an entity from the identity map. Any subsequent changes to the
|
|
54
|
+
* object will not be persisted by `saveChanges`.
|
|
55
|
+
*/
|
|
56
|
+
detach(entity: object): void;
|
|
57
|
+
/**
|
|
58
|
+
* Mark a tracked entity for deletion on the next `saveChanges()` call.
|
|
59
|
+
*/
|
|
60
|
+
remove(entity: object): void;
|
|
61
|
+
/**
|
|
62
|
+
* Get the public entry-state view for a tracked entity.
|
|
63
|
+
*/
|
|
64
|
+
entry<T extends object>(entity: T): EntityEntry<T>;
|
|
65
|
+
/**
|
|
66
|
+
* Register a callback that is invoked for each changed entry just before
|
|
67
|
+
* the changes are flushed to the database. Use to apply audit fields etc.
|
|
68
|
+
*/
|
|
69
|
+
onSavingChanges(hook: SavingChangesHook): void;
|
|
70
|
+
/**
|
|
71
|
+
* Reload a tracked entity from the database, overwriting its current
|
|
72
|
+
* values and refreshing the internal snapshot.
|
|
73
|
+
*/
|
|
74
|
+
reload(entity: object): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Discard all pending changes. Modified entries are reset to their
|
|
77
|
+
* snapshots; Added entries are detached; Deleted entries are restored
|
|
78
|
+
* to `Unchanged`.
|
|
79
|
+
*/
|
|
80
|
+
discardChanges(): void;
|
|
81
|
+
/**
|
|
82
|
+
* Flush all pending changes to the database inside a single transaction.
|
|
83
|
+
*
|
|
84
|
+
* @returns Counts of inserted, updated, and deleted rows.
|
|
85
|
+
*/
|
|
86
|
+
saveChanges(): Promise<{
|
|
87
|
+
inserted: number;
|
|
88
|
+
updated: number;
|
|
89
|
+
deleted: number;
|
|
90
|
+
}>;
|
|
91
|
+
/**
|
|
92
|
+
* Implements `Symbol.asyncDispose` for `await using` blocks.
|
|
93
|
+
*
|
|
94
|
+
* Throws {@link PendingChangesError} when there are unsaved changes at
|
|
95
|
+
* the time of disposal — callers must call `saveChanges()` or
|
|
96
|
+
* `discardChanges()` before the scope exits.
|
|
97
|
+
*/
|
|
98
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Create a typed database context.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* const TodoEntity = defineEntity(TodoSchema)
|
|
106
|
+
* .belongsTo(t => t.author, l => l.userId, r => r.id);
|
|
107
|
+
*
|
|
108
|
+
* const UserEntity = defineEntity(UserSchema);
|
|
109
|
+
*
|
|
110
|
+
* const db = createDb(knex, { todos: TodoEntity, users: UserEntity });
|
|
111
|
+
*
|
|
112
|
+
* const todo = await db.todos
|
|
113
|
+
* .where(t => t.id, '=', 42)
|
|
114
|
+
* .include(t => t.author)
|
|
115
|
+
* .first();
|
|
116
|
+
*
|
|
117
|
+
* await db.transaction(async dbTrx => {
|
|
118
|
+
* const u = await dbTrx.users.insert({ email: 'a@b.c', role: 'user', authProvider: 'local', createdAt: new Date() });
|
|
119
|
+
* await dbTrx.todos.insert({ title: 'Hi', userId: u.id, completed: false, createdAt: new Date(), updatedAt: new Date() });
|
|
120
|
+
* });
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* @public
|
|
124
|
+
*/
|
|
125
|
+
export declare function createDb<TMap extends EntityMap>(knex: Knex, entities: TMap, opts: {
|
|
126
|
+
tracking: true;
|
|
127
|
+
}): TrackedDbContext<TMap>;
|
|
128
|
+
export declare function createDb<TMap extends EntityMap>(knex: Knex, entities: TMap, opts?: {
|
|
129
|
+
tracking?: false | undefined;
|
|
130
|
+
}): DbContext<TMap>;
|