@t4h.framework/orm 0.11.4 → 0.11.5
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/.ai/skills/framework-orm/SKILL.md +83 -301
- package/CHANGELOG.md +6 -0
- package/README.md +20 -278
- package/package.json +2 -2
|
@@ -1,351 +1,133 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: framework-orm
|
|
3
3
|
description: >-
|
|
4
|
-
Guides
|
|
5
|
-
decorators (@Entity, @Property, @Index
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
Guides the use of @t4h.framework/orm in T4H Framework workflows: entity
|
|
5
|
+
decorators (@Entity, @Property, @Index, @ManyToOne, @OneToMany), the ORM
|
|
6
|
+
class (create, upsert, find, findOne, exists, delete), Ref, RefCollection and
|
|
7
|
+
MapCollection, getSnapshot with populate, where operators ($in, $like, $or,
|
|
8
|
+
...), ConstraintError, and ORMClaim. Use when you define database entities,
|
|
9
|
+
read or write rows in a workflow, model relations between tables, give or
|
|
10
|
+
mock ORMClaim in a test with TestWorkflowEnvironment, or write an ORMClaim
|
|
11
|
+
implementation.
|
|
10
12
|
---
|
|
11
13
|
|
|
12
14
|
# @t4h.framework/orm
|
|
13
15
|
|
|
14
|
-
ORM
|
|
16
|
+
Each `ORM`, `Ref` and `RefCollection` call is an activity recorded in the
|
|
17
|
+
workflow history. The runtime gives the database through `ORMClaim`.
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
## Define an entity
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
ORMDeleteActivity,
|
|
25
|
-
getEntityMetadata, getEntityTableName, getEntityProperties,
|
|
26
|
-
type EntityInsertData, type EntityPartialData, type FindWhere,
|
|
27
|
-
type SerializableCreateInput, type SerializableFindInput,
|
|
28
|
-
type SerializableGetSnapshotInput, type SerializableUpdateInput,
|
|
29
|
-
type SerializableRemoveInput, type SerializableDeleteInput,
|
|
30
|
-
} from '@t4h.framework/orm'
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Node `>=22`. See [README.md](../../../README.md).
|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
## Architecture
|
|
38
|
-
|
|
39
|
-
`Workflow` → `ORM` / `Ref` methods (`History.bind` on ORM activities) → `ORMClaim` (runtime database adapter).
|
|
40
|
-
|
|
41
|
-
| Layer | Role |
|
|
42
|
-
|-------|------|
|
|
43
|
-
| **ORM** | High-level API; applies defaults + constraints before activities |
|
|
44
|
-
| **Ref** / **RefCollection** | Row handles returned by create/find |
|
|
45
|
-
| **Activities** | Shipped, replay-safe activities the `ORM`/`Ref` APIs drive; delegate to `ORMClaim` |
|
|
46
|
-
| **ORMClaim** | Abstract claim; runtime provides concrete DB implementation |
|
|
47
|
-
|
|
48
|
-
---
|
|
49
|
-
|
|
50
|
-
## Defining entities
|
|
51
|
-
|
|
52
|
-
Set `experimentalDecorators` and `emitDecoratorMetadata` to `true` in the `compilerOptions` of the project `tsconfig.json`.
|
|
53
|
-
|
|
54
|
-
Import `reflect-metadata` where decorators are evaluated (Property decorator imports it).
|
|
55
|
-
|
|
56
|
-
### @Entity
|
|
21
|
+
1. Enable `experimentalDecorators` and `emitDecoratorMetadata` in
|
|
22
|
+
`tsconfig.json`.
|
|
23
|
+
2. Decorate the class with `@Entity({ tableName })` (required option) and each
|
|
24
|
+
column with `@Property`.
|
|
25
|
+
3. Declare `id` and each property with a `default` as optional. Also set
|
|
26
|
+
`nullable: true` on `id`: the store sets it.
|
|
57
27
|
|
|
58
28
|
```typescript
|
|
59
29
|
@Entity({ tableName: 'users' })
|
|
60
30
|
class User {
|
|
61
|
-
@Property({ type: '
|
|
62
|
-
public
|
|
31
|
+
@Property({ type: 'uuid', nullable: true })
|
|
32
|
+
public id?: string
|
|
63
33
|
|
|
64
|
-
@Property({ type: 'string' })
|
|
34
|
+
@Property({ type: 'string', index: true })
|
|
65
35
|
public email!: string
|
|
66
36
|
|
|
67
37
|
@Property({ type: 'boolean', default: true })
|
|
68
|
-
public active
|
|
38
|
+
public active?: boolean
|
|
69
39
|
}
|
|
70
40
|
```
|
|
71
41
|
|
|
72
|
-
|
|
73
|
-
- Use `getEntityTableName(User)` or rely on ORM to resolve the table name.
|
|
42
|
+
## Create the ORM once
|
|
74
43
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
Omit `type` to infer from design metadata (`String`→`string`, `Number`→`number`, etc.). Declare domain types (`uuid`) explicitly. Validated types: `string`, `uuid`, `number`, `boolean`, `array`, `date`, `object`. No `type` → skip type validation.
|
|
80
|
-
|
|
81
|
-
### @Index
|
|
82
|
-
|
|
83
|
-
Class-level composite indexes:
|
|
84
|
-
|
|
85
|
-
```typescript
|
|
86
|
-
@Index({ properties: ['tenantId', 'email'], name: 'accounts_tenant_email_idx' })
|
|
87
|
-
class Account { /* ... */ }
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
An empty `properties` throws (`decorators/Index.ts`). Per-property `@Property({ index: true })` also registers `{ properties: [propertyKey] }`.
|
|
91
|
-
|
|
92
|
-
---
|
|
93
|
-
|
|
94
|
-
## ORM configuration
|
|
95
|
-
|
|
96
|
-
`ORM` extends `SerializableClass` — instances can be serialized for workflow persistence. `connectionString` is typed `Secret` (a string is a `tsc` error): pass the `EnvDesign` entry declared `type: Secret`. The host reads the value where each query runs.
|
|
44
|
+
Declare `DATABASE_URL: { type: Secret }` with `EnvDesign` in
|
|
45
|
+
`src/constants/environments.ts`, and create the `ORM` at module level, not in
|
|
46
|
+
the workflow function:
|
|
97
47
|
|
|
98
48
|
```typescript
|
|
99
49
|
import { environments } from '../constants/environments.js'
|
|
100
50
|
|
|
101
|
-
const orm = new ORM({
|
|
102
|
-
connectionString: environments.DATABASE_URL,
|
|
103
|
-
entities: [User,
|
|
104
|
-
})
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
- `entities`: array of entity **constructors** (decorated classes).
|
|
108
|
-
- `config.entities` stores resolved `EntityMetadata` (table name, properties map, indexes).
|
|
109
|
-
|
|
110
|
-
Restore from serialized state:
|
|
111
|
-
|
|
112
|
-
```typescript
|
|
113
|
-
const orm = await ORM.fromSerializable(serialized, v => v)
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
---
|
|
117
|
-
|
|
118
|
-
## CRUD in workflows
|
|
119
|
-
|
|
120
|
-
ORM methods bind activities via `History.bind` — calls inside a workflow are **reconciler-driven** (replay-safe).
|
|
121
|
-
|
|
122
|
-
### Create
|
|
123
|
-
|
|
124
|
-
```typescript
|
|
125
|
-
// Single row → Ref<T>
|
|
126
|
-
const userRef = await orm.create(User, {
|
|
127
|
-
name: 'John',
|
|
128
|
-
email: 'john@example.com',
|
|
129
|
-
active: true,
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
// Batch → RefCollection<T>
|
|
133
|
-
const usersRef = await orm.create(User, [
|
|
134
|
-
{ name: 'Alice', email: 'alice@example.com', active: true },
|
|
135
|
-
{ name: 'Bob', email: 'bob@example.com', active: true },
|
|
136
|
-
])
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
Defaults are applied before persistence (`applyDefaultProperties`). Omitted fields with `@Property({ default })` are filled in.
|
|
140
|
-
|
|
141
|
-
### Upsert
|
|
142
|
-
|
|
143
|
-
```typescript
|
|
144
|
-
// Single row → Ref<T>; array → RefCollection<T>
|
|
145
|
-
const userRef = await orm.upsert(
|
|
146
|
-
User,
|
|
147
|
-
{ email: 'john@example.com', name: 'John', active: true },
|
|
148
|
-
{ conflictOn: ['email'] },
|
|
149
|
-
)
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
Rows are validated like `create`. If a row matching `conflictOn` (equality on every listed property) exists, only the passed fields are written; defaults apply on insert only. An empty `conflictOn`, or a row with no value for a `conflictOn` property, throws (`ConstraintError` for a missing key, `Error` for an empty `conflictOn`). The call is a single `ORMUpsertActivity` backed by `ORMClaim.upsert`, which has a default (`find`, then `create` or `update`) — mock `upsert` in tests, or `find`/`create`/`update` if the mock extends `ORMClaim`. A retry after a crash updates instead of duplicating, but it is not atomic across workflows: concurrent writes of the same key may both insert.
|
|
153
|
-
|
|
154
|
-
### Find
|
|
155
|
-
|
|
156
|
-
```typescript
|
|
157
|
-
const userRef = await orm.findOne(User, {
|
|
158
|
-
where: { email: 'john@example.com' },
|
|
159
|
-
orderBy: { name: 'asc' },
|
|
160
|
-
}) // Ref<User> | null
|
|
161
|
-
|
|
162
|
-
const usersRef = await orm.find(User, {
|
|
163
|
-
where: { active: true },
|
|
164
|
-
orderBy: { name: 'asc' },
|
|
165
|
-
limit: 10,
|
|
166
|
-
offset: 0,
|
|
167
|
-
}) // RefCollection<User>
|
|
168
|
-
|
|
169
|
-
const exists = await orm.exists(User, {
|
|
170
|
-
where: { email: 'john@example.com' },
|
|
171
|
-
}) // boolean (delegates to findOne)
|
|
172
|
-
|
|
173
|
-
const deleted = await orm.delete(User, {
|
|
174
|
-
where: { active: false },
|
|
175
|
-
}) // number — deletes every match; `{}` empties the table
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
`findOne` activity internally calls `claim.find` with `limit: 1`.
|
|
179
|
-
|
|
180
|
-
### Ref operations
|
|
181
|
-
|
|
182
|
-
```typescript
|
|
183
|
-
const snapshot = await userRef.getSnapshot() // T | null — entity instance
|
|
184
|
-
await userRef.update({ name: 'Jane' }) // partial patch only
|
|
185
|
-
await userRef.remove()
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
`getSnapshot` returns `null` when the row no longer exists. `update` validates only keys present in the patch.
|
|
189
|
-
|
|
190
|
-
**RefCollection:** `toArray()`, `length`, `at(i)`, async `forEach`/`map`/`filter`, `Symbol.iterator`.
|
|
191
|
-
|
|
192
|
-
---
|
|
193
|
-
|
|
194
|
-
## Query operators
|
|
195
|
-
|
|
196
|
-
`where` uses `FindWhere<T>` — implicit equality or operator objects:
|
|
197
|
-
|
|
198
|
-
```typescript
|
|
199
|
-
await orm.find(User, {
|
|
200
|
-
where: {
|
|
201
|
-
name: 'John', // $eq (implicit)
|
|
202
|
-
active: { $ne: false },
|
|
203
|
-
email: { $like: '%@example%' },
|
|
204
|
-
age: { $gte: 18, $lt: 65 },
|
|
205
|
-
status: { $in: ['active', 'pending'] },
|
|
206
|
-
$or: [{ name: 'Alice' }, { name: 'Bob' }],
|
|
207
|
-
$and: [{ active: true }, { age: { $gte: 18 } }],
|
|
208
|
-
},
|
|
51
|
+
export const orm = new ORM({
|
|
52
|
+
connectionString: environments.DATABASE_URL,
|
|
53
|
+
entities: [User, Post],
|
|
209
54
|
})
|
|
210
55
|
```
|
|
211
56
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
---
|
|
215
|
-
|
|
216
|
-
## Constraint validation
|
|
217
|
-
|
|
218
|
-
Validation runs in the ORM library (`ORM.create`, `ORM.upsert` and `Ref.update`), **not** in `ORMClaim` implementations. Failures throw `ConstraintError`.
|
|
219
|
-
|
|
220
|
-
### Create pipeline
|
|
221
|
-
|
|
222
|
-
1. `applyDefaultProperties` — fills `default` for `undefined` fields
|
|
223
|
-
2. `assertPropertyTypeConstraint` — validates declared types
|
|
224
|
-
3. `assertNonNullablePropertyConstraint` — required fields must be present
|
|
225
|
-
|
|
226
|
-
### Update pipeline
|
|
227
|
-
|
|
228
|
-
1. Defaults only for keys present in the patch
|
|
229
|
-
2. Same asserts, scoped to patch keys via `{ keys: Object.keys(data) }`
|
|
230
|
-
|
|
231
|
-
### Handling errors
|
|
57
|
+
## Read and write
|
|
232
58
|
|
|
233
59
|
```typescript
|
|
234
|
-
|
|
60
|
+
const userRef = await orm.create(User, { email: input.email }) // Ref<User>
|
|
61
|
+
const found = await orm.findOne(User, { where: { email: input.email } }) // Ref<User> | null
|
|
62
|
+
const active = await orm.find(User, { where: { active: true }, limit: 10 }) // RefCollection<User>
|
|
235
63
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (error instanceof ConstraintError) {
|
|
240
|
-
// e.g. 'Property "name" is required'
|
|
241
|
-
// e.g. 'Property "name" must be of type string'
|
|
242
|
-
}
|
|
243
|
-
}
|
|
64
|
+
const user = await userRef.getSnapshot() // User | null
|
|
65
|
+
await userRef.update({ active: false }) // partial patch
|
|
66
|
+
const snapshots = await active.getSnapshots() // one activity for all refs
|
|
244
67
|
```
|
|
245
68
|
|
|
246
|
-
|
|
69
|
+
## Relations
|
|
247
70
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
Runtime must register a concrete implementation:
|
|
71
|
+
The `@ManyToOne` side owns the column and is a `Ref<T>`. The `@OneToMany` side
|
|
72
|
+
is a lazy `MapCollection<T>`. Read related rows in the same activity with
|
|
73
|
+
`populate`:
|
|
253
74
|
|
|
254
75
|
```typescript
|
|
255
|
-
|
|
256
|
-
|
|
76
|
+
@OneToMany(() => Post, post => post.author)
|
|
77
|
+
public posts!: MapCollection<Post>
|
|
257
78
|
|
|
258
|
-
|
|
79
|
+
@ManyToOne(() => User)
|
|
80
|
+
public author!: Ref<User>
|
|
259
81
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
abstract find(input: SerializableFindInput): R[] | Promise<R[]>
|
|
263
|
-
abstract create(input: SerializableCreateInput): R[] | Promise<R[]>
|
|
264
|
-
abstract getSnapshot(input: SerializableGetSnapshotInput<R>): SerializedRow | null | Promise<SerializedRow | null>
|
|
265
|
-
abstract update(input: SerializableUpdateInput<R>): void | Promise<void>
|
|
266
|
-
abstract remove(input: SerializableRemoveInput<R>): void | Promise<void>
|
|
267
|
-
upsert(input: SerializableUpsertInput): Promise<R[]> // default: find, then create/update; override for a native upsert
|
|
268
|
-
abstract delete(input: SerializableDeleteInput): number | Promise<number>
|
|
269
|
-
}
|
|
82
|
+
const user = await userRef.getSnapshot({ populate: ['posts.comments'] })
|
|
83
|
+
const titles = await user?.posts.map(post => post.title)
|
|
270
84
|
```
|
|
271
85
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
##
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
export const orm = new ORM({ connectionString: environments.DATABASE_URL, entities: [User] })
|
|
300
|
-
|
|
301
|
-
export const ormWorkflow = new Workflow({ id: 'orm' }, async () => {
|
|
302
|
-
await orm.create(User, { name: 'John', email: 'john@example.com', age: 20 })
|
|
303
|
-
const user = await orm.findOne(User, { where: { email: 'john@example.com' } })
|
|
304
|
-
await user?.update({ name: 'John Updated' })
|
|
305
|
-
await user?.remove()
|
|
306
|
-
return { user }
|
|
307
|
-
})
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
Ensure the runtime provides `ORMClaim`.
|
|
311
|
-
|
|
312
|
-
---
|
|
313
|
-
|
|
314
|
-
## Testing
|
|
315
|
-
|
|
316
|
-
Test the **workflow** with `TestWorkflowEnvironment` from
|
|
317
|
-
`@t4h.framework/core/testing`, mocking `ORMClaim` — the single claim the ORM
|
|
318
|
-
activities resolve. Provide the methods your workflow triggers (`create`, `upsert`, `find`,
|
|
319
|
-
`findOne`, `getSnapshot`, `update`, `remove`). See the **framework-workflow-testing**
|
|
320
|
-
skill for the harness API.
|
|
86
|
+
## Traps
|
|
87
|
+
|
|
88
|
+
- `getSnapshot()` returns `null` when the row no longer exists. A populated
|
|
89
|
+
`@ManyToOne` can also be `null`, also when the relation is not nullable.
|
|
90
|
+
- A snapshot is a copy. Write through the `Ref` (`ref.update`), and use
|
|
91
|
+
`ref.load('author')` to get the `Ref` of a related row.
|
|
92
|
+
- `MapCollection` is not a promise. Call a method (`toArray()`, `map()`,
|
|
93
|
+
`count()`); do not `await user.posts`.
|
|
94
|
+
- `refs.map(ref => ref.getSnapshot())` runs one activity per ref. Use
|
|
95
|
+
`refs.getSnapshots()` or `populate`.
|
|
96
|
+
- A populate is the output of one activity and goes into the history. Keep it
|
|
97
|
+
narrow on large relations; filter with `where` and `limit`.
|
|
98
|
+
- `orm.delete(User, { where: {} })` deletes every row of the table.
|
|
99
|
+
- `upsert` needs a non-empty `conflictOn` and a value for each listed property.
|
|
100
|
+
It is not atomic across workflows: two workflows can both insert.
|
|
101
|
+
- A bad row throws `ConstraintError` before an activity runs.
|
|
102
|
+
- Do not construct or extend the `ORM*Activity` classes. The lint rule
|
|
103
|
+
`@t4h.framework/core/no-framework-activity-in-app` rejects it. Use `ORM` and
|
|
104
|
+
`Ref`.
|
|
105
|
+
- To write a relation, give a `Ref` (or the data of a new row in `create`).
|
|
106
|
+
You cannot write the `@OneToMany` side of an existing row.
|
|
107
|
+
|
|
108
|
+
## Test
|
|
109
|
+
|
|
110
|
+
Give `ORMClaim` with `mockClaim` and mock only what the workflow calls.
|
|
111
|
+
`ORMClaim` has no `findOne` (it calls `find` with `limit: 1`), and
|
|
112
|
+
`getSnapshot` on a `Ref` calls `getSnapshots`:
|
|
321
113
|
|
|
322
114
|
```typescript
|
|
323
115
|
import { TestWorkflowEnvironment, mockClaim } from '@t4h.framework/core/testing'
|
|
324
116
|
import { ORMClaim } from '@t4h.framework/orm'
|
|
325
117
|
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
const env = TestWorkflowEnvironment.create({ now: 0 })
|
|
118
|
+
const create = vi.fn(async () => [{ id: 1 }])
|
|
119
|
+
const getSnapshots = vi.fn(async () => [{ id: 1, email: 'john@example.com' }])
|
|
330
120
|
|
|
331
|
-
await
|
|
332
|
-
claims: [mockClaim(ORMClaim, { create
|
|
121
|
+
await TestWorkflowEnvironment.create().execute(onboard, input, {
|
|
122
|
+
claims: [mockClaim(ORMClaim, { create, getSnapshots })],
|
|
333
123
|
})
|
|
334
124
|
```
|
|
335
125
|
|
|
336
|
-
|
|
337
|
-
activity runs, so you can assert an invalid input rejects without the claim being
|
|
338
|
-
called: `expect(mockCreate).not.toHaveBeenCalled()`.
|
|
339
|
-
|
|
340
|
-
---
|
|
341
|
-
|
|
342
|
-
## Checklist for agents
|
|
343
|
-
|
|
344
|
-
1. **`@Entity` + `@Property`** on each entity class.
|
|
345
|
-
2. Register entities in **`ORM({ entities: [...] })`**; runtime provides **`ORMClaim`**.
|
|
346
|
-
3. Use **`orm` / `Ref` APIs** in workflows — not raw SQL or direct claim calls.
|
|
347
|
-
4. Handle **`ConstraintError`**; validation runs before activities.
|
|
348
|
-
5. **`Ref.update`** is partial; **`getSnapshot`** returns entity instances.
|
|
349
|
-
6. Test workflows with **`TestWorkflowEnvironment`** + **`mockClaim(ORMClaim, ...)`**.
|
|
126
|
+
## Reference
|
|
350
127
|
|
|
351
|
-
|
|
128
|
+
- Overview, runtime and tests: https://tech4humans-brasil.github.io/framework/orm/
|
|
129
|
+
- Entities and validation: https://tech4humans-brasil.github.io/framework/orm/entities
|
|
130
|
+
- Queries, upsert, delete, operators: https://tech4humans-brasil.github.io/framework/orm/queries
|
|
131
|
+
- `Ref` and `RefCollection`: https://tech4humans-brasil.github.io/framework/orm/records
|
|
132
|
+
- Relations, populate, `MapCollection`: https://tech4humans-brasil.github.io/framework/orm/relations
|
|
133
|
+
- Types: `node_modules/@t4h.framework/orm/dist/orm.d.ts`
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @t4h.framework/orm
|
|
2
2
|
|
|
3
|
+
## 0.11.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#257](https://github.com/tech4humans-brasil/framework/pull/257) [`586bfb4`](https://github.com/tech4humans-brasil/framework/commit/586bfb4663f4a478527ee0c8db360603f034d615) Thanks [@gusteycamargo](https://github.com/gusteycamargo)! - The orm README is short and points to the docs site. The docs and the skill now match the source: the entity examples declare `id` as optional, and a test mocks `find` and `getSnapshots` of `ORMClaim`, not `findOne`.
|
|
8
|
+
|
|
3
9
|
## 0.11.4
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,308 +1,50 @@
|
|
|
1
1
|
# @t4h.framework/orm
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Durable, replay-safe database access for workflows. You describe tables as
|
|
4
|
+
decorated classes and read and write them through `ORM`, `Ref` and
|
|
5
|
+
`RefCollection`. The runtime gives the database through `ORMClaim`.
|
|
4
6
|
|
|
5
|
-
##
|
|
7
|
+
## Installation
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Abstract dependency for database operations. The runtime must provide a concrete implementation (e.g. PostgreSQL, SQLite).
|
|
10
|
-
|
|
11
|
-
```typescript
|
|
12
|
-
import { ORMClaim } from '@t4h.framework/orm'
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
### Interface
|
|
16
|
-
|
|
17
|
-
```typescript
|
|
18
|
-
abstract class ORMClaim<R extends SerializableRowRef> {
|
|
19
|
-
public abstract find(input: SerializableFindInput): R[] | Promise<R[]>
|
|
20
|
-
public abstract create(input: SerializableCreateInput): R[] | Promise<R[]>
|
|
21
|
-
public abstract getSnapshot(input: SerializableGetSnapshotInput<R>): SerializedRow | Promise<SerializedRow>
|
|
22
|
-
public abstract update(input: SerializableUpdateInput<R>): void | Promise<void>
|
|
23
|
-
public abstract remove(input: SerializableRemoveInput<R>): void | Promise<void>
|
|
24
|
-
public abstract getSnapshots(input: SerializableGetSnapshotsInput<R>): (SerializedRow | null)[] | Promise<(SerializedRow | null)[]>
|
|
25
|
-
public abstract delete(input: SerializableDeleteInput): number | Promise<number>
|
|
26
|
-
|
|
27
|
-
// Default: find, then create or update. Override for a native upsert.
|
|
28
|
-
public upsert(input: SerializableUpsertInput): Promise<R[]>
|
|
29
|
-
}
|
|
9
|
+
```bash
|
|
10
|
+
npm install @t4h.framework/orm
|
|
30
11
|
```
|
|
31
12
|
|
|
32
|
-
|
|
13
|
+
Peer dependency: `@t4h.framework/core`. Enable `experimentalDecorators` and
|
|
14
|
+
`emitDecoratorMetadata` in `tsconfig.json`.
|
|
33
15
|
|
|
34
|
-
|
|
16
|
+
## Usage
|
|
35
17
|
|
|
36
18
|
```typescript
|
|
37
|
-
import {
|
|
19
|
+
import { Workflow } from '@t4h.framework/core'
|
|
20
|
+
import { Entity, ORM, Property } from '@t4h.framework/orm'
|
|
21
|
+
|
|
22
|
+
import { environments } from './constants/environments.js'
|
|
38
23
|
|
|
39
24
|
@Entity({ tableName: 'users' })
|
|
40
25
|
class User {
|
|
41
26
|
@Property({ type: 'uuid', nullable: true })
|
|
42
27
|
public id?: string
|
|
43
28
|
|
|
44
|
-
@Property({ type: 'string' })
|
|
45
|
-
public name!: string
|
|
46
|
-
|
|
47
29
|
@Property({ type: 'string' })
|
|
48
30
|
public email!: string
|
|
49
|
-
|
|
50
|
-
@Property({ type: 'boolean', default: true })
|
|
51
|
-
public active?: boolean
|
|
52
31
|
}
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
`id` is assigned by the store on create, so declare it optional and `nullable`
|
|
56
|
-
— a non-nullable property missing from the data fails
|
|
57
|
-
`assertNonNullablePropertyConstraint`, and `EntityInsertData` would require you
|
|
58
|
-
to pass it yourself. The same goes for any property carrying a `default`.
|
|
59
|
-
|
|
60
|
-
### @Entity options
|
|
61
|
-
|
|
62
|
-
```typescript
|
|
63
|
-
@Entity({ tableName?: string })
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
### @Property options
|
|
67
|
-
|
|
68
|
-
```typescript
|
|
69
|
-
@Property({
|
|
70
|
-
type?: string,
|
|
71
|
-
nullable?: boolean,
|
|
72
|
-
default?: any,
|
|
73
|
-
index?: boolean,
|
|
74
|
-
})
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
When `type` is omitted, it is inferred from the TypeScript property type if the project enables `emitDecoratorMetadata` (and imports `reflect-metadata`). Domain types such as `uuid` must be declared explicitly.
|
|
78
|
-
|
|
79
|
-
If `nullable` is omitted, the property is required at runtime validation.
|
|
80
|
-
|
|
81
|
-
### TypeScript configuration for entity classes
|
|
82
|
-
|
|
83
|
-
Projects that define entities with `@Property()` should enable:
|
|
84
|
-
|
|
85
|
-
```json
|
|
86
|
-
{
|
|
87
|
-
"compilerOptions": {
|
|
88
|
-
"experimentalDecorators": true,
|
|
89
|
-
"emitDecoratorMetadata": true
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## Constraints
|
|
95
|
-
|
|
96
|
-
Row validation runs in the ORM library (`ORM.create`, `ORM.upsert` and `Ref.update`), not in `ORMClaim` implementations. Failed checks throw `ConstraintError`.
|
|
97
|
-
|
|
98
|
-
### Pipeline
|
|
99
|
-
|
|
100
|
-
**Create** (full row, after defaults):
|
|
101
|
-
|
|
102
|
-
1. `applyDefaultProperties` — fills `default` for `undefined` fields
|
|
103
|
-
2. `assertPropertyTypeConstraint` — validates declared types
|
|
104
|
-
3. `assertNonNullablePropertyConstraint` — required fields must be present
|
|
105
|
-
|
|
106
|
-
**Update** (partial patch only):
|
|
107
|
-
|
|
108
|
-
1. Defaults only for keys present in the patch
|
|
109
|
-
2. Same asserts, scoped to patch keys
|
|
110
|
-
|
|
111
|
-
### Supported property types
|
|
112
|
-
|
|
113
|
-
`string`, `uuid`, `number`, `boolean`, `array`, `date`, `object`. Properties without `type` skip type validation.
|
|
114
|
-
|
|
115
|
-
### Example
|
|
116
|
-
|
|
117
|
-
```typescript
|
|
118
|
-
import { ConstraintError, ORM } from '@t4h.framework/orm'
|
|
119
|
-
|
|
120
|
-
try {
|
|
121
|
-
await orm.create(User, { email: 'taken@example.com' })
|
|
122
|
-
} catch (error) {
|
|
123
|
-
if (error instanceof ConstraintError) {
|
|
124
|
-
// handle validation failure
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
## ORM Model
|
|
130
|
-
|
|
131
|
-
The `ORM` class provides a high-level API for database operations. It wraps the underlying activities (`ORMCreateActivity`, `ORMFindActivity`, `ORMFindOneActivity`).
|
|
132
|
-
|
|
133
|
-
### Creating records
|
|
134
|
-
|
|
135
|
-
The connection string is a `Secret`, so `tsc` rejects a string. Declare it with `type: Secret` in the `EnvDesign` in `src/constants/environments.ts`:
|
|
136
|
-
|
|
137
|
-
```typescript
|
|
138
|
-
// src/constants/environments.ts
|
|
139
|
-
import { EnvDesign, Secret } from '@t4h.framework/core'
|
|
140
|
-
|
|
141
|
-
export const environments = EnvDesign({
|
|
142
|
-
DATABASE_URL: { type: Secret },
|
|
143
|
-
})
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
```typescript
|
|
147
|
-
import { ORM } from '@t4h.framework/orm'
|
|
148
|
-
|
|
149
|
-
import { environments } from './constants/environments.js'
|
|
150
32
|
|
|
151
33
|
const orm = new ORM({
|
|
152
34
|
connectionString: environments.DATABASE_URL,
|
|
153
35
|
entities: [User],
|
|
154
36
|
})
|
|
155
37
|
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
email: 'john@example.com',
|
|
159
|
-
active: true,
|
|
160
|
-
})
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
### Batch create
|
|
164
|
-
|
|
165
|
-
```typescript
|
|
166
|
-
const usersRef = await orm.create(User, [
|
|
167
|
-
{ name: 'Alice', email: 'alice@example.com', active: true },
|
|
168
|
-
{ name: 'Bob', email: 'bob@example.com', active: true },
|
|
169
|
-
])
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
### Upsert
|
|
173
|
-
|
|
174
|
-
```typescript
|
|
175
|
-
// Inserts, or updates the row with the same email
|
|
176
|
-
const userRef = await orm.upsert(
|
|
177
|
-
User,
|
|
178
|
-
{ email: 'john@example.com', name: 'John', active: true },
|
|
179
|
-
{ conflictOn: ['email'] },
|
|
180
|
-
)
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
Pass an array for a batch (returns a `RefCollection`). On update only the fields you passed are written; defaults apply on insert only. The call runs as a single `ORMUpsertActivity`, so a retry after a crash updates instead of duplicating; it is not atomic across workflows, so concurrent writes of the same key may both insert.
|
|
184
|
-
|
|
185
|
-
### Finding records
|
|
186
|
-
|
|
187
|
-
```typescript
|
|
188
|
-
const userRef = await orm.findOne(User, {
|
|
189
|
-
where: { email: 'john@example.com' },
|
|
190
|
-
})
|
|
38
|
+
export const onboard = new Workflow({ id: 'onboard' }, async () => {
|
|
39
|
+
const userRef = await orm.create(User, { email: 'john@example.com' })
|
|
191
40
|
|
|
192
|
-
|
|
193
|
-
where: { active: true },
|
|
194
|
-
orderBy: { name: 'asc' },
|
|
195
|
-
limit: 10,
|
|
196
|
-
offset: 0,
|
|
41
|
+
return await userRef.getSnapshot()
|
|
197
42
|
})
|
|
198
43
|
```
|
|
199
44
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
```typescript
|
|
203
|
-
const exists = await orm.exists(User, {
|
|
204
|
-
where: { email: 'john@example.com' },
|
|
205
|
-
})
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
### Deleting records
|
|
209
|
-
|
|
210
|
-
```typescript
|
|
211
|
-
const deleted = await orm.delete(User, {
|
|
212
|
-
where: { active: false },
|
|
213
|
-
}) // number of deleted rows
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
`where` is required; an empty `{}` deletes every row.
|
|
217
|
-
|
|
218
|
-
## Ref
|
|
219
|
-
|
|
220
|
-
A `Ref<T>` is a reference to a single entity row. It supports `getSnapshot`, `update`, and `remove`:
|
|
221
|
-
|
|
222
|
-
```typescript
|
|
223
|
-
const userRef = await orm.findOne(User, { where: { id: '...' } })
|
|
224
|
-
|
|
225
|
-
const user = await userRef.getSnapshot() // returns the full entity
|
|
226
|
-
await userRef.update({ name: 'Jane' }) // partial update
|
|
227
|
-
await userRef.remove() // delete
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
## RefCollection
|
|
231
|
-
|
|
232
|
-
A `RefCollection<T>` is a collection of entity references returned by `find` or batch `create`:
|
|
233
|
-
|
|
234
|
-
```typescript
|
|
235
|
-
const usersRef = await orm.find(User, { where: { active: true } })
|
|
236
|
-
|
|
237
|
-
const users = usersRef.toArray() // Ref<User>[]
|
|
238
|
-
usersRef.forEach(ref => { /* ... */ })
|
|
239
|
-
await usersRef.getSnapshots() // (User | null)[] in one activity
|
|
240
|
-
usersRef.filter(ref => { /* ... */ })
|
|
241
|
-
usersRef.at(0) // Ref<User> | undefined
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
## Relations
|
|
245
|
-
|
|
246
|
-
`@ManyToOne` owns the relation and stores the related row in a column; `@OneToMany` reads it back from the other table. The to-one side is a `Ref<T>`, the many side a lazy `MapCollection<T>`:
|
|
247
|
-
|
|
248
|
-
```typescript
|
|
249
|
-
@Entity({ tableName: 'users' })
|
|
250
|
-
class User {
|
|
251
|
-
@OneToMany(() => Post, post => post.author)
|
|
252
|
-
public posts!: MapCollection<Post>
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
@Entity({ tableName: 'posts' })
|
|
256
|
-
class Post {
|
|
257
|
-
@ManyToOne(() => User)
|
|
258
|
-
public author!: Ref<User>
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
await orm.create(Post, { title: 'Hello', author: userRef })
|
|
262
|
-
|
|
263
|
-
const aliceRef = await orm.findOne(User, { where: { name: 'Alice' } })
|
|
264
|
-
|
|
265
|
-
const user = await aliceRef!.getSnapshot({
|
|
266
|
-
populate: ['posts'], // read in the same activity
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
await user!.posts.map(post => post.title)
|
|
270
|
-
|
|
271
|
-
const posts = await aliceRef!.load('posts') // MapCollection<Post>, read when used
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
See [docs/relations.md](./docs/relations.md).
|
|
275
|
-
|
|
276
|
-
## Query Operators
|
|
277
|
-
|
|
278
|
-
The `where` clause supports the following operators:
|
|
279
|
-
|
|
280
|
-
```typescript
|
|
281
|
-
await orm.find(User, {
|
|
282
|
-
where: {
|
|
283
|
-
name: 'John', // $eq (implicit)
|
|
284
|
-
active: { $ne: false }, // not equal
|
|
285
|
-
email: { $like: '%@example%' }, // LIKE
|
|
286
|
-
age: { $gte: 18, $lt: 65 }, // range
|
|
287
|
-
},
|
|
288
|
-
})
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
Available operators: `$eq`, `$ne`, `$in`, `$nin`, `$not`, `$gt`, `$gte`, `$lt`, `$lte`, `$like`, `$ilike`, `$and`, `$or`.
|
|
292
|
-
|
|
293
|
-
## Activities
|
|
294
|
-
|
|
295
|
-
The package includes the following activities (used internally by `ORM` and `Ref`):
|
|
45
|
+
`environments` comes from `EnvDesign` in `src/constants/environments.ts`, with
|
|
46
|
+
`DATABASE_URL` declared as `{ type: Secret }`.
|
|
296
47
|
|
|
297
|
-
|
|
298
|
-
- `ORMFindActivity` -- finds multiple rows
|
|
299
|
-
- `ORMFindOneActivity` -- finds a single row
|
|
300
|
-
- `ORMGetSnapshotActivity` -- retrieves full entity data from a ref (no longer used by `Ref.getSnapshot`)
|
|
301
|
-
- `ORMLoadActivity` -- backs `getSnapshot` / `getSnapshots`: retrieves rows with their relations and `populate` in one activity
|
|
302
|
-
- `ORMLoadRelationActivity` -- follows a to-one relation from one or more refs, returning refs
|
|
303
|
-
- `ORMUpdateActivity` -- updates a row
|
|
304
|
-
- `ORMUpsertActivity` -- inserts or updates rows by `conflictOn` (via `ORMClaim.upsert`)
|
|
305
|
-
- `ORMRemoveActivity` -- removes a row
|
|
306
|
-
- `ORMDeleteActivity` -- deletes every row matching a `where`
|
|
48
|
+
## Documentation
|
|
307
49
|
|
|
308
|
-
|
|
50
|
+
https://tech4humans-brasil.github.io/framework/orm/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@t4h.framework/orm",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.5",
|
|
4
4
|
"description": "ORM module for the T4H Framework",
|
|
5
5
|
"homepage": "https://github.com/tech4humans-brasil/framework/tree/main/packages/orm",
|
|
6
6
|
"bugs": "https://github.com/tech4humans-brasil/framework/issues",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"reflect-metadata": "^0.2.2"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@t4h.framework/core": "^0.
|
|
39
|
+
"@t4h.framework/core": "^0.21.0",
|
|
40
40
|
"typescript": "^5.9.3",
|
|
41
41
|
"vitest": "^4.0.18"
|
|
42
42
|
},
|