interaqt 0.3.0 → 0.4.0
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/agent/.claude/agents/code-generation-handler.md +2 -0
- package/agent/.claude/agents/computation-generation-handler.md +2 -3
- package/agent/.claude/agents/implement-design-handler.md +4 -13
- package/agent/.claude/agents/requirements-analysis-handler.md +88 -16
- package/agent/agentspace/knowledge/generator/api-reference.md +3815 -0
- package/agent/agentspace/knowledge/generator/basic-interaction-generation.md +377 -0
- package/agent/agentspace/knowledge/generator/computation-analysis.md +309 -0
- package/agent/agentspace/knowledge/generator/computation-implementation.md +983 -0
- package/agent/agentspace/knowledge/generator/data-analysis.md +484 -0
- package/agent/agentspace/knowledge/generator/entity-relation-generation.md +395 -0
- package/agent/agentspace/knowledge/generator/permission-implementation.md +460 -0
- package/agent/agentspace/knowledge/generator/permission-test-implementation.md +870 -0
- package/agent/agentspace/knowledge/generator/test-implementation.md +674 -0
- package/agent/agentspace/knowledge/usage/00-mindset-shift.md +322 -0
- package/agent/agentspace/knowledge/usage/01-core-concepts.md +131 -0
- package/agent/agentspace/knowledge/usage/02-define-entities-properties.md +407 -0
- package/agent/agentspace/knowledge/usage/03-entity-relations.md +599 -0
- package/agent/agentspace/knowledge/usage/04-reactive-computations.md +2186 -0
- package/agent/agentspace/knowledge/usage/05-interactions.md +1411 -0
- package/agent/agentspace/knowledge/usage/06-attributive-permissions.md +10 -0
- package/agent/agentspace/knowledge/usage/07-payload-parameters.md +593 -0
- package/agent/agentspace/knowledge/usage/08-activities.md +863 -0
- package/agent/agentspace/knowledge/usage/09-filtered-entities.md +784 -0
- package/agent/agentspace/knowledge/usage/10-async-computations.md +734 -0
- package/agent/agentspace/knowledge/usage/11-global-dictionaries.md +942 -0
- package/agent/agentspace/knowledge/usage/12-data-querying.md +1033 -0
- package/agent/agentspace/knowledge/usage/13-testing.md +1201 -0
- package/agent/agentspace/knowledge/usage/14-api-reference.md +1606 -0
- package/agent/agentspace/knowledge/usage/15-entity-crud-patterns.md +1122 -0
- package/agent/agentspace/knowledge/usage/16-frontend-page-design-guide.md +485 -0
- package/agent/agentspace/knowledge/usage/17-performance-optimization.md +283 -0
- package/agent/agentspace/knowledge/usage/18-api-exports-reference.md +176 -0
- package/agent/agentspace/knowledge/usage/19-common-anti-patterns.md +563 -0
- package/agent/agentspace/knowledge/usage/README.md +148 -0
- package/dist/index.js +2977 -2976
- package/dist/index.js.map +1 -1
- package/dist/runtime/ComputationSourceMap.d.ts +11 -21
- package/dist/runtime/ComputationSourceMap.d.ts.map +1 -1
- package/dist/runtime/Controller.d.ts +2 -2
- package/dist/runtime/MonoSystem.d.ts.map +1 -1
- package/dist/runtime/Scheduler.d.ts +6 -6
- package/dist/runtime/Scheduler.d.ts.map +1 -1
- package/dist/runtime/System.d.ts +5 -0
- package/dist/runtime/System.d.ts.map +1 -1
- package/dist/runtime/computations/Computation.d.ts +4 -9
- package/dist/runtime/computations/Computation.d.ts.map +1 -1
- package/dist/runtime/computations/StateMachine.d.ts +4 -7
- package/dist/runtime/computations/StateMachine.d.ts.map +1 -1
- package/dist/runtime/computations/Transform.d.ts +7 -1
- package/dist/runtime/computations/Transform.d.ts.map +1 -1
- package/dist/runtime/computations/TransitionFinder.d.ts +2 -2
- package/dist/runtime/computations/TransitionFinder.d.ts.map +1 -1
- package/dist/shared/StateTransfer.d.ts +15 -5
- package/dist/shared/StateTransfer.d.ts.map +1 -1
- package/dist/shared/Transform.d.ts +17 -3
- package/dist/shared/Transform.d.ts.map +1 -1
- package/package.json +1 -1
- package/agent/.claude/agents/requirements-analysis-handler-bak.md +0 -530
|
@@ -0,0 +1,3815 @@
|
|
|
1
|
+
# Chapter 13: API Reference
|
|
2
|
+
|
|
3
|
+
This chapter provides detailed reference documentation for all core APIs in the interaqt framework, including complete parameter descriptions, type definitions, and usage examples.
|
|
4
|
+
|
|
5
|
+
## 13.1 Entity-Related APIs
|
|
6
|
+
|
|
7
|
+
### Entity.create()
|
|
8
|
+
|
|
9
|
+
Create entity definition. Entities are the basic units of data in the system.
|
|
10
|
+
|
|
11
|
+
**Syntax**
|
|
12
|
+
```typescript
|
|
13
|
+
Entity.create(config: EntityConfig): EntityInstance
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**Parameters**
|
|
17
|
+
- `config.name` (string, required): Entity name, must match `/^[a-zA-Z0-9_]+$/` format
|
|
18
|
+
- `config.properties` (Property[], required): Entity property list, defaults to empty array
|
|
19
|
+
- `config.computation` (Computation[], optional): Entity-level computed data
|
|
20
|
+
- `config.baseEntity` (Entity|Relation, optional): Base entity for filtered entity (used to create filtered entities)
|
|
21
|
+
- `config.matchExpression` (MatchExp, optional): Match expression (used to create filtered entities)
|
|
22
|
+
|
|
23
|
+
**Examples**
|
|
24
|
+
```typescript
|
|
25
|
+
// Create basic entity
|
|
26
|
+
const User = Entity.create({
|
|
27
|
+
name: 'User',
|
|
28
|
+
properties: [
|
|
29
|
+
Property.create({ name: 'username', type: 'string' }),
|
|
30
|
+
Property.create({ name: 'email', type: 'string' })
|
|
31
|
+
]
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Create filtered entity
|
|
35
|
+
const ActiveUser = Entity.create({
|
|
36
|
+
name: 'ActiveUser',
|
|
37
|
+
baseEntity: User,
|
|
38
|
+
matchExpression: MatchExp.atom({
|
|
39
|
+
key: 'status',
|
|
40
|
+
value: ['=', 'active']
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Filtered Entities**
|
|
46
|
+
|
|
47
|
+
Filtered entities are views of existing entities that automatically filter records based on specified conditions. They support:
|
|
48
|
+
|
|
49
|
+
1. **Cascade Filtering**: Filtered entities can be used as `baseEntity` to create new filtered entities:
|
|
50
|
+
```typescript
|
|
51
|
+
// First level filter
|
|
52
|
+
const ActiveUser = Entity.create({
|
|
53
|
+
name: 'ActiveUser',
|
|
54
|
+
baseEntity: User,
|
|
55
|
+
matchExpression: MatchExp.atom({
|
|
56
|
+
key: 'isActive',
|
|
57
|
+
value: ['=', true]
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Second level filter - based on ActiveUser
|
|
62
|
+
const TechActiveUser = Entity.create({
|
|
63
|
+
name: 'TechActiveUser',
|
|
64
|
+
baseEntity: ActiveUser, // Using filtered entity as base
|
|
65
|
+
matchExpression: MatchExp.atom({
|
|
66
|
+
key: 'department',
|
|
67
|
+
value: ['=', 'Tech']
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Third level filter - even more specific
|
|
72
|
+
const SeniorTechActiveUser = Entity.create({
|
|
73
|
+
name: 'SeniorTechActiveUser',
|
|
74
|
+
baseEntity: TechActiveUser,
|
|
75
|
+
matchExpression: MatchExp.atom({
|
|
76
|
+
key: 'role',
|
|
77
|
+
value: ['=', 'senior']
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
2. **Complex Conditions**: Use boolean expressions for complex filtering:
|
|
83
|
+
```typescript
|
|
84
|
+
const TechYoungUser = Entity.create({
|
|
85
|
+
name: 'TechYoungUser',
|
|
86
|
+
baseEntity: User,
|
|
87
|
+
matchExpression: MatchExp.atom({
|
|
88
|
+
key: 'age',
|
|
89
|
+
value: ['<', 30]
|
|
90
|
+
}).and({
|
|
91
|
+
key: 'department',
|
|
92
|
+
value: ['=', 'Tech']
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
3. **Automatic Updates**: When source entity records are created, updated, or deleted, filtered entities automatically reflect these changes based on their match expressions.
|
|
98
|
+
|
|
99
|
+
### Property.create()
|
|
100
|
+
|
|
101
|
+
Create entity property definition.
|
|
102
|
+
|
|
103
|
+
**Syntax**
|
|
104
|
+
```typescript
|
|
105
|
+
Property.create(config: PropertyConfig): PropertyInstance
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Parameters**
|
|
109
|
+
- `config.name` (string, required): Property name, must be 1-5 characters long
|
|
110
|
+
- `config.type` (string, required): Property type, options: 'string' | 'number' | 'boolean'
|
|
111
|
+
- `config.collection` (boolean, optional): Whether it's a collection type
|
|
112
|
+
- `config.defaultValue` (function, optional): Default value function
|
|
113
|
+
- `config.computed` (function, optional): Computed property function
|
|
114
|
+
- `config.computation` (Computation, optional): Property computed data
|
|
115
|
+
|
|
116
|
+
**⚠️ IMPORTANT: Timestamp Properties**
|
|
117
|
+
When creating timestamp properties with `defaultValue`, **always convert milliseconds to seconds** using `Math.floor(Date.now()/1000)`:
|
|
118
|
+
- ❌ WRONG: `defaultValue: () => Date.now()` - Returns milliseconds, database doesn't support this!
|
|
119
|
+
- ✅ CORRECT: `defaultValue: () => Math.floor(Date.now()/1000)` - Returns Unix timestamp in seconds
|
|
120
|
+
|
|
121
|
+
**Examples**
|
|
122
|
+
```typescript
|
|
123
|
+
// Basic property
|
|
124
|
+
const username = Property.create({
|
|
125
|
+
name: 'username',
|
|
126
|
+
type: 'string'
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// Property with default value - timestamp
|
|
130
|
+
// 🔴 CRITICAL: Always use seconds for timestamps, not milliseconds!
|
|
131
|
+
const createdAt = Property.create({
|
|
132
|
+
name: 'createdAt',
|
|
133
|
+
type: 'number',
|
|
134
|
+
defaultValue: () => Math.floor(Date.now()/1000) // Convert to seconds - database doesn't support milliseconds!
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// Computed property
|
|
138
|
+
const fullName = Property.create({
|
|
139
|
+
name: 'fullName',
|
|
140
|
+
type: 'string',
|
|
141
|
+
computed: function(user) {
|
|
142
|
+
return `${user.firstName} ${user.lastName}`
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Property with reactive computation
|
|
147
|
+
const postCount = Property.create({
|
|
148
|
+
name: 'postCount',
|
|
149
|
+
type: 'number',
|
|
150
|
+
computation: Count.create({
|
|
151
|
+
property: 'posts' // Use property name from relation
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### HardDeletionProperty.create()
|
|
157
|
+
|
|
158
|
+
Create a special property that triggers physical deletion of records when set to true.
|
|
159
|
+
|
|
160
|
+
**Syntax**
|
|
161
|
+
```typescript
|
|
162
|
+
HardDeletionProperty.create(config?: HardDeletionPropertyConfig): PropertyInstance
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Parameters**
|
|
166
|
+
- `config.name` (string, optional): Property name, defaults to `HARD_DELETION_PROPERTY_NAME`
|
|
167
|
+
|
|
168
|
+
**Usage with StateMachine**
|
|
169
|
+
|
|
170
|
+
HardDeletionProperty is typically used with StateMachine to manage record deletion:
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
import { DELETED_STATE, NON_DELETED_STATE, HARD_DELETION_PROPERTY_NAME } from 'interaqt'
|
|
174
|
+
|
|
175
|
+
// Define deletion StateMachine for the HardDeletionProperty
|
|
176
|
+
const deletionStateMachine = StateMachine.create({
|
|
177
|
+
states: [NON_DELETED_STATE, DELETED_STATE],
|
|
178
|
+
defaultState: NON_DELETED_STATE,
|
|
179
|
+
transfers: [
|
|
180
|
+
StateTransfer.create({
|
|
181
|
+
trigger: {
|
|
182
|
+
recordName: InteractionEventEntity.name,
|
|
183
|
+
record: {
|
|
184
|
+
interactionName: DeleteUserInteraction.name
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
current: NON_DELETED_STATE,
|
|
188
|
+
next: DELETED_STATE,
|
|
189
|
+
computeTarget: function(event) {
|
|
190
|
+
return { id: event.payload.userId }
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
]
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const hardDeletionProperty = HardDeletionProperty.create()
|
|
197
|
+
hardDeletionProperty.computation = deletionStateMachine
|
|
198
|
+
|
|
199
|
+
// Add HardDeletionProperty to entity
|
|
200
|
+
const User = Entity.create({
|
|
201
|
+
name: 'User',
|
|
202
|
+
properties: [
|
|
203
|
+
Property.create({ name: 'name', type: 'string' }),
|
|
204
|
+
Property.create({ name: 'email', type: 'string' }),
|
|
205
|
+
// Add HardDeletionProperty with deletion StateMachine
|
|
206
|
+
hardDeletionProperty
|
|
207
|
+
]
|
|
208
|
+
})
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**How It Works**
|
|
212
|
+
1. When the property value transitions to `DELETED_STATE` (true), the Controller automatically deletes the record from storage
|
|
213
|
+
2. The deletion is physical - the record is completely removed from the database
|
|
214
|
+
3. This is different from soft deletion where records remain but are marked as deleted
|
|
215
|
+
|
|
216
|
+
**Note**: When you need to find the HardDeletionProperty after entity creation, use:
|
|
217
|
+
```typescript
|
|
218
|
+
const hardDeletionProp = entity.properties.find(p => p.name === HARD_DELETION_PROPERTY_NAME)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Example: Entity Creation and Deletion Pattern**
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
// Use Transform for entity creation
|
|
225
|
+
const Article = Entity.create({
|
|
226
|
+
name: 'Article',
|
|
227
|
+
properties: [
|
|
228
|
+
Property.create({ name: 'title', type: 'string' }),
|
|
229
|
+
Property.create({ name: 'content', type: 'string' }),
|
|
230
|
+
Property.create({ name: 'slug', type: 'string' }),
|
|
231
|
+
HardDeletionProperty.create()
|
|
232
|
+
],
|
|
233
|
+
// Transform creates new articles from interaction events
|
|
234
|
+
computation: Transform.create({
|
|
235
|
+
eventDeps: {
|
|
236
|
+
ArticleInteraction: {
|
|
237
|
+
recordName: InteractionEventEntity.name,
|
|
238
|
+
type: 'create'
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
callback: async function(this: Controller, mutationEvent) {
|
|
242
|
+
const event = mutationEvent.record;
|
|
243
|
+
if (event.interactionName === 'CreateArticle') {
|
|
244
|
+
// Use Controller to generate article slug
|
|
245
|
+
const existingCount = await this.system.storage.find('Article',
|
|
246
|
+
undefined,
|
|
247
|
+
undefined,
|
|
248
|
+
['id']
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
title: event.payload.title,
|
|
253
|
+
content: event.payload.content,
|
|
254
|
+
slug: `article-${existingCount.length + 1}`
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// Configure deletion for the HardDeletionProperty
|
|
263
|
+
const deletionProperty = HardDeletionProperty.create()
|
|
264
|
+
deletionProperty.computation = StateMachine.create({
|
|
265
|
+
states: [NON_DELETED_STATE, DELETED_STATE],
|
|
266
|
+
defaultState: NON_DELETED_STATE,
|
|
267
|
+
transfers: [
|
|
268
|
+
StateTransfer.create({
|
|
269
|
+
trigger: {
|
|
270
|
+
recordName: InteractionEventEntity.name,
|
|
271
|
+
record: {
|
|
272
|
+
interactionName: DeleteArticleInteraction.name
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
current: NON_DELETED_STATE,
|
|
276
|
+
next: DELETED_STATE,
|
|
277
|
+
computeTarget: function(event) {
|
|
278
|
+
return { id: event.payload.articleId }
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
]
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
Article.properties.push(deletionProperty)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Relation.create()
|
|
288
|
+
|
|
289
|
+
Create relationship definition between entities or create filtered views of existing relations.
|
|
290
|
+
|
|
291
|
+
**Syntax**
|
|
292
|
+
```typescript
|
|
293
|
+
Relation.create(config: RelationConfig): RelationInstance
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
**Two Types of Relations**
|
|
297
|
+
1. **Base Relations**: Define direct relationships between entities (requires `source`, `target`, `type`)
|
|
298
|
+
2. **Filtered Relations**: Create filtered views of existing relations (requires `baseRelation`, `matchExpression`)
|
|
299
|
+
|
|
300
|
+
**Important: Auto-Generated Relation Names**
|
|
301
|
+
|
|
302
|
+
If you did not specify a `name` property when creating relations, The framework will automatically generates the relation name based on the source and target entities. For example:
|
|
303
|
+
- A relation between `User` and `Post` → automatically named `UserPost`
|
|
304
|
+
- A relation between `Post` and `Comment` → automatically named `PostComment`
|
|
305
|
+
|
|
306
|
+
**Parameters**
|
|
307
|
+
- `config.name` (string, optional): Relation name. If not specified, will be auto-generated from source and target entity names
|
|
308
|
+
- `config.source` (Entity|Relation, required for base relations): Source entity of the relationship
|
|
309
|
+
- `config.sourceProperty` (string, required): Relationship property name in source entity
|
|
310
|
+
- `config.target` (Entity|Relation, required for base relations): Target entity of the relationship
|
|
311
|
+
- `config.targetProperty` (string, required): Relationship property name in target entity
|
|
312
|
+
- `config.type` (string, required for base relations): Relationship type, options: '1:1' | '1:n' | 'n:1' | 'n:n'
|
|
313
|
+
- `config.properties` (Property[], optional): Properties of the relationship itself
|
|
314
|
+
- `config.computation` (Computation, optional): Relationship-level computed data
|
|
315
|
+
- `config.baseRelation` (Relation, required for filtered relations): Base relation to filter from
|
|
316
|
+
- `config.matchExpression` (MatchExp, required for filtered relations): Filter condition for the relation records
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
**Note on Symmetric Relations**: The system automatically detects symmetric relations when `source === target` AND `sourceProperty === targetProperty`. There is no need to specify a `symmetric` parameter.
|
|
320
|
+
|
|
321
|
+
**Examples**
|
|
322
|
+
```typescript
|
|
323
|
+
// One-to-many relationship
|
|
324
|
+
const UserPostRelation = Relation.create({
|
|
325
|
+
source: User,
|
|
326
|
+
sourceProperty: 'posts',
|
|
327
|
+
target: Post,
|
|
328
|
+
targetProperty: 'author',
|
|
329
|
+
type: '1:n'
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// Many-to-many relationship
|
|
333
|
+
const UserTagRelation = Relation.create({
|
|
334
|
+
source: User,
|
|
335
|
+
sourceProperty: 'tags',
|
|
336
|
+
target: Tag,
|
|
337
|
+
targetProperty: 'users',
|
|
338
|
+
type: 'n:n'
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// Symmetric relationship (friendship)
|
|
342
|
+
// Note: System detects symmetric relations automatically when source === target AND sourceProperty === targetProperty
|
|
343
|
+
const FriendRelation = Relation.create({
|
|
344
|
+
source: User,
|
|
345
|
+
sourceProperty: 'friends',
|
|
346
|
+
target: User,
|
|
347
|
+
targetProperty: 'friends', // Same as sourceProperty - automatically symmetric
|
|
348
|
+
type: 'n:n'
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
// Relationship with properties
|
|
352
|
+
const UserRoleRelation = Relation.create({
|
|
353
|
+
source: User,
|
|
354
|
+
sourceProperty: 'roles',
|
|
355
|
+
target: Role,
|
|
356
|
+
targetProperty: 'users',
|
|
357
|
+
type: 'n:n',
|
|
358
|
+
properties: [
|
|
359
|
+
Property.create({ name: 'assignedAt', type: 'string' }),
|
|
360
|
+
Property.create({ name: 'isActive', type: 'boolean' })
|
|
361
|
+
]
|
|
362
|
+
})
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
**Filtered Relations**
|
|
366
|
+
|
|
367
|
+
Filtered relations are views of existing relations that automatically filter relationship records based on specified conditions. They support:
|
|
368
|
+
|
|
369
|
+
1. **Basic Filtered Relations**: Create filtered views based on relation properties:
|
|
370
|
+
```typescript
|
|
371
|
+
// Base relation with properties
|
|
372
|
+
const UserPostRelation = Relation.create({
|
|
373
|
+
source: User,
|
|
374
|
+
sourceProperty: 'posts',
|
|
375
|
+
target: Post,
|
|
376
|
+
targetProperty: 'author',
|
|
377
|
+
type: '1:n',
|
|
378
|
+
properties: [
|
|
379
|
+
Property.create({ name: 'isPublished', type: 'boolean' }),
|
|
380
|
+
Property.create({ name: 'priority', type: 'string' })
|
|
381
|
+
]
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// Filtered relation - only published posts
|
|
385
|
+
const PublishedUserPostRelation = Relation.create({
|
|
386
|
+
name: 'PublishedUserPostRelation',
|
|
387
|
+
baseRelation: UserPostRelation,
|
|
388
|
+
sourceProperty: 'publishedPosts',
|
|
389
|
+
targetProperty: 'publishedAuthor',
|
|
390
|
+
matchExpression: MatchExp.atom({
|
|
391
|
+
key: 'isPublished',
|
|
392
|
+
value: ['=', true]
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
2. **Cascade Filtering**: Filtered relations can be used as `baseRelation` to create new filtered relations:
|
|
398
|
+
```typescript
|
|
399
|
+
// First level filter - active assignments
|
|
400
|
+
const ActiveUserProjectRelation = Relation.create({
|
|
401
|
+
name: 'ActiveUserProjectRelation',
|
|
402
|
+
baseRelation: UserProjectRelation,
|
|
403
|
+
sourceProperty: 'activeProjects',
|
|
404
|
+
targetProperty: 'activeUsers',
|
|
405
|
+
matchExpression: MatchExp.atom({
|
|
406
|
+
key: 'isActive',
|
|
407
|
+
value: ['=', true]
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
// Second level filter - only lead roles from active assignments
|
|
412
|
+
const LeadUserProjectRelation = Relation.create({
|
|
413
|
+
name: 'LeadUserProjectRelation',
|
|
414
|
+
baseRelation: ActiveUserProjectRelation, // Using filtered relation as base
|
|
415
|
+
sourceProperty: 'leadProjects',
|
|
416
|
+
targetProperty: 'leadUsers',
|
|
417
|
+
matchExpression: MatchExp.atom({
|
|
418
|
+
key: 'role',
|
|
419
|
+
value: ['=', 'lead']
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
3. **Complex Filter Conditions**: Combine multiple conditions:
|
|
425
|
+
```typescript
|
|
426
|
+
const ImportantActiveRelation = Relation.create({
|
|
427
|
+
name: 'ImportantActiveRelation',
|
|
428
|
+
baseRelation: UserTaskRelation,
|
|
429
|
+
sourceProperty: 'importantActiveTasks',
|
|
430
|
+
targetProperty: 'assignedToImportant',
|
|
431
|
+
matchExpression: MatchExp.atom({
|
|
432
|
+
key: 'priority',
|
|
433
|
+
value: ['=', 'high']
|
|
434
|
+
}).and({
|
|
435
|
+
key: 'status',
|
|
436
|
+
value: ['=', 'active']
|
|
437
|
+
})
|
|
438
|
+
})
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
4. **Automatic Updates**: When source relation records are created, updated, or deleted, filtered relations automatically reflect these changes based on their match expressions. For example, if a relation's `isActive` property is updated from `false` to `true`, it will automatically appear in the corresponding filtered relation.
|
|
442
|
+
|
|
443
|
+
## 13.2 Computation-Related APIs
|
|
444
|
+
|
|
445
|
+
### Count.create()
|
|
446
|
+
|
|
447
|
+
Create count computation for counting records.
|
|
448
|
+
|
|
449
|
+
**Syntax**
|
|
450
|
+
```typescript
|
|
451
|
+
Count.create(config: CountConfig): CountInstance
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
**Parameters**
|
|
455
|
+
- `config.record` (Entity|Relation, optional): Entity or relation to count (for entity/global level computation)
|
|
456
|
+
- `config.property` (string, optional): Property name from relation (for property level computation)
|
|
457
|
+
- `config.direction` (string, optional): Relationship direction, options: 'source' | 'target', only for relation counting
|
|
458
|
+
- `config.callback` (function, optional): Filter callback function, returns boolean to decide if included in count
|
|
459
|
+
- `config.attributeQuery` (AttributeQueryData, optional): Attribute query configuration to optimize data fetching
|
|
460
|
+
- `config.dataDeps` (object, optional): Data dependency configuration, format: `{[key: string]: DataDep}`
|
|
461
|
+
|
|
462
|
+
**Examples**
|
|
463
|
+
```typescript
|
|
464
|
+
// Basic global count
|
|
465
|
+
const totalUsers = Count.create({
|
|
466
|
+
record: User
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
// Basic property count (user's post count)
|
|
470
|
+
const userPostCount = Property.create({
|
|
471
|
+
name: 'postCount',
|
|
472
|
+
type: 'number',
|
|
473
|
+
computation: Count.create({
|
|
474
|
+
property: 'posts' // Use property name from relation
|
|
475
|
+
})
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
// Count with filter condition (only count published posts)
|
|
479
|
+
const publishedPostCount = Property.create({
|
|
480
|
+
name: 'publishedPostCount',
|
|
481
|
+
type: 'number',
|
|
482
|
+
computation: Count.create({
|
|
483
|
+
property: 'posts', // Use property name from relation
|
|
484
|
+
attributeQuery: ['status'], // Query properties on related entity
|
|
485
|
+
callback: function(post) {
|
|
486
|
+
return post.status === 'published'
|
|
487
|
+
}
|
|
488
|
+
})
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
// Count with data dependencies (filter based on global minimum score setting)
|
|
492
|
+
const minScoreThreshold = Dictionary.create({
|
|
493
|
+
name: 'minScoreThreshold',
|
|
494
|
+
type: 'number',
|
|
495
|
+
collection: false
|
|
496
|
+
})
|
|
497
|
+
const highScorePostCount = Property.create({
|
|
498
|
+
name: 'highScorePostCount',
|
|
499
|
+
type: 'number',
|
|
500
|
+
computation: Count.create({
|
|
501
|
+
property: 'posts', // Use property name from relation
|
|
502
|
+
attributeQuery: ['score'], // Query properties on related entity
|
|
503
|
+
dataDeps: {
|
|
504
|
+
minScore: {
|
|
505
|
+
type: 'global',
|
|
506
|
+
source: minScoreThreshold
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
callback: function(post, dataDeps) {
|
|
510
|
+
return post.score >= dataDeps.minScore
|
|
511
|
+
}
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
// Global count with filter and data dependencies
|
|
516
|
+
const userActiveDays = Dictionary.create({
|
|
517
|
+
name: 'userActiveDays',
|
|
518
|
+
type: 'number',
|
|
519
|
+
collection: false
|
|
520
|
+
})
|
|
521
|
+
const activeUsersCount = Dictionary.create({
|
|
522
|
+
name: 'activeUsersCount',
|
|
523
|
+
type: 'number',
|
|
524
|
+
collection: false,
|
|
525
|
+
computation: Count.create({
|
|
526
|
+
record: User,
|
|
527
|
+
attributeQuery: ['lastLoginDate'],
|
|
528
|
+
dataDeps: {
|
|
529
|
+
activeDays: {
|
|
530
|
+
type: 'global',
|
|
531
|
+
source: userActiveDays
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
callback: function(user, dataDeps) {
|
|
535
|
+
const daysSinceLogin = (Date.now() - new Date(user.lastLoginDate).getTime()) / (1000 * 60 * 60 * 24)
|
|
536
|
+
return daysSinceLogin <= dataDeps.activeDays
|
|
537
|
+
}
|
|
538
|
+
})
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
// Relation count with direction parameter
|
|
542
|
+
const authorPostCount = Property.create({
|
|
543
|
+
name: 'authoredPostCount',
|
|
544
|
+
type: 'number',
|
|
545
|
+
computation: Count.create({
|
|
546
|
+
property: 'posts', // Use property name from relation
|
|
547
|
+
})
|
|
548
|
+
})
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
### WeightedSummation.create()
|
|
552
|
+
|
|
553
|
+
Create weighted summation computation.
|
|
554
|
+
|
|
555
|
+
**Syntax**
|
|
556
|
+
```typescript
|
|
557
|
+
WeightedSummation.create(config: WeightedSummationConfig): WeightedSummationInstance
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
**Parameters**
|
|
561
|
+
- `config.record` (Entity|Relation, optional): Entity or relation to compute (for entity/global level computation)
|
|
562
|
+
- `config.property` (string, optional): Property name from relation (for property level computation)
|
|
563
|
+
- `config.callback` (function, required): Callback function to calculate weight and value, returns `{weight: number, value: number}`
|
|
564
|
+
- `config.attributeQuery` (AttributeQueryData, required): Attribute query configuration
|
|
565
|
+
|
|
566
|
+
**Examples**
|
|
567
|
+
```typescript
|
|
568
|
+
// Calculate user total score
|
|
569
|
+
const userTotalScore = Property.create({
|
|
570
|
+
name: 'totalScore',
|
|
571
|
+
type: 'number',
|
|
572
|
+
computation: WeightedSummation.create({
|
|
573
|
+
property: 'scores', // Use property name from relation
|
|
574
|
+
callback: function(scoreRecord) {
|
|
575
|
+
return {
|
|
576
|
+
weight: scoreRecord.multiplier || 1,
|
|
577
|
+
value: scoreRecord.points
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
// Global weighted summation
|
|
584
|
+
const globalWeightedScore = WeightedSummation.create({
|
|
585
|
+
record: ScoreRecord,
|
|
586
|
+
callback: function(record) {
|
|
587
|
+
return {
|
|
588
|
+
weight: record.difficulty,
|
|
589
|
+
value: record.score
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
})
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Summation.create()
|
|
596
|
+
|
|
597
|
+
Create summation computation for summing specified fields.
|
|
598
|
+
|
|
599
|
+
**Syntax**
|
|
600
|
+
```typescript
|
|
601
|
+
Summation.create(config: SummationConfig): SummationInstance
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
**Parameters**
|
|
605
|
+
- `config.record` (Entity|Relation, optional): Entity or relation to compute (for entity/global level computation)
|
|
606
|
+
- `config.property` (string, optional): Property name from relation (for property level computation)
|
|
607
|
+
- `config.attributeQuery` (AttributeQueryData, required): Attribute query configuration, specifies field path to sum
|
|
608
|
+
- `config.direction` (string, optional): Relationship direction, options: 'source' | 'target', only for relation summation
|
|
609
|
+
|
|
610
|
+
**How it works**
|
|
611
|
+
|
|
612
|
+
Summation sums the field pointed to by the leftmost path in `attributeQuery`. If any value in the path is `undefined`, `null`, `NaN`, or `Infinity`, that value will be treated as 0.
|
|
613
|
+
|
|
614
|
+
**Examples**
|
|
615
|
+
```typescript
|
|
616
|
+
// Basic global summation (sum all transaction amounts)
|
|
617
|
+
const totalRevenue = Dictionary.create({
|
|
618
|
+
name: 'totalRevenue',
|
|
619
|
+
type: 'number',
|
|
620
|
+
collection: false,
|
|
621
|
+
computation: Summation.create({
|
|
622
|
+
record: Transaction,
|
|
623
|
+
attributeQuery: ['amount']
|
|
624
|
+
})
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
// Property-level summation (calculate user's total order amount)
|
|
628
|
+
const userTotalSpent = Property.create({
|
|
629
|
+
name: 'totalSpent',
|
|
630
|
+
type: 'number',
|
|
631
|
+
computation: Summation.create({
|
|
632
|
+
property: 'orders', // Use property name from relation
|
|
633
|
+
attributeQuery: ['totalAmount'] // Query properties on related entity
|
|
634
|
+
})
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
// Nested path summation (sum nested fields of related entities)
|
|
638
|
+
const departmentBudget = Property.create({
|
|
639
|
+
name: 'totalBudget',
|
|
640
|
+
type: 'number',
|
|
641
|
+
computation: Summation.create({
|
|
642
|
+
property: 'projects', // Use property name from relation
|
|
643
|
+
attributeQuery: [['budget', {
|
|
644
|
+
attributeQuery: ['allocatedAmount']
|
|
645
|
+
}]] // Query nested properties on related entity
|
|
646
|
+
})
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
// Direct summation of relation properties
|
|
650
|
+
const totalShippingCost = Property.create({
|
|
651
|
+
name: 'totalShippingCost',
|
|
652
|
+
type: 'number',
|
|
653
|
+
computation: Summation.create({
|
|
654
|
+
property: 'shipments', // Use property name from relation
|
|
655
|
+
attributeQuery: [['&', {attributeQuery: ['shippingFee']}]] // Access relation's own property with '&'
|
|
656
|
+
})
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
// Global summation handling missing values
|
|
660
|
+
const totalBalance = Dictionary.create({
|
|
661
|
+
name: 'totalBalance',
|
|
662
|
+
type: 'number',
|
|
663
|
+
collection: false,
|
|
664
|
+
computation: Summation.create({
|
|
665
|
+
record: Account,
|
|
666
|
+
attributeQuery: ['balance'] // null or undefined values treated as 0
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
**Working with Other Computations**
|
|
672
|
+
|
|
673
|
+
If you need complex summation logic (like conditional filtering, data transformation etc.), you can first use other computations (like Transform) to calculate the needed values on records, then use Summation for simple summing:
|
|
674
|
+
|
|
675
|
+
```typescript
|
|
676
|
+
// First use Transform to calculate discounted price
|
|
677
|
+
const OrderItem = Entity.create({
|
|
678
|
+
name: 'OrderItem',
|
|
679
|
+
properties: [
|
|
680
|
+
Property.create({ name: 'price', type: 'number' }),
|
|
681
|
+
Property.create({ name: 'quantity', type: 'number' }),
|
|
682
|
+
Property.create({ name: 'discountRate', type: 'number' }),
|
|
683
|
+
Property.create({
|
|
684
|
+
name: 'finalPrice',
|
|
685
|
+
type: 'number',
|
|
686
|
+
computed: function(item) {
|
|
687
|
+
const subtotal = (item.price || 0) * (item.quantity || 0);
|
|
688
|
+
const discount = subtotal * (item.discountRate || 0);
|
|
689
|
+
return subtotal - discount;
|
|
690
|
+
}
|
|
691
|
+
})
|
|
692
|
+
]
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// Then use Summation to sum computed values
|
|
696
|
+
const orderTotal = Property.create({
|
|
697
|
+
name: 'total',
|
|
698
|
+
type: 'number',
|
|
699
|
+
computation: Summation.create({
|
|
700
|
+
property: 'items', // Use property name from relation
|
|
701
|
+
attributeQuery: ['finalPrice'] // Query properties on related entity
|
|
702
|
+
})
|
|
703
|
+
});
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
### Every.create()
|
|
707
|
+
|
|
708
|
+
Create boolean computation that checks if all records meet a condition.
|
|
709
|
+
|
|
710
|
+
**Syntax**
|
|
711
|
+
```typescript
|
|
712
|
+
Every.create(config: EveryConfig): EveryInstance
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
**Parameters**
|
|
716
|
+
- `config.record` (Entity|Relation, optional): Entity or relation to check (for entity/global level computation)
|
|
717
|
+
- `config.property` (string, optional): Property name from relation (for property level computation)
|
|
718
|
+
- `config.callback` (function, required): Condition check function, returns boolean
|
|
719
|
+
- `config.attributeQuery` (AttributeQueryData, required): Attribute query configuration
|
|
720
|
+
- `config.notEmpty` (boolean, optional): Return value when collection is empty
|
|
721
|
+
|
|
722
|
+
**Examples**
|
|
723
|
+
```typescript
|
|
724
|
+
// Check if user completed all required courses
|
|
725
|
+
const completedAllRequired = Property.create({
|
|
726
|
+
name: 'completedAllRequired',
|
|
727
|
+
type: 'boolean',
|
|
728
|
+
computation: Every.create({
|
|
729
|
+
property: 'courses', // Use property name from relation
|
|
730
|
+
attributeQuery: [['&', {attributeQuery: ['status']}]], // Access relation's own property with '&'
|
|
731
|
+
callback: function(courseRelation) {
|
|
732
|
+
return courseRelation['&'].status === 'completed'
|
|
733
|
+
},
|
|
734
|
+
notEmpty: false
|
|
735
|
+
})
|
|
736
|
+
})
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
### Any.create()
|
|
740
|
+
|
|
741
|
+
Create boolean computation that checks if any record meets a condition.
|
|
742
|
+
|
|
743
|
+
**Syntax**
|
|
744
|
+
```typescript
|
|
745
|
+
Any.create(config: AnyConfig): AnyInstance
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
**Parameters**
|
|
749
|
+
- `config.record` (Entity|Relation, optional): Entity or relation to check (for entity/global level computation)
|
|
750
|
+
- `config.property` (string, optional): Property name from relation (for property level computation)
|
|
751
|
+
- `config.callback` (function, required): Condition check function, returns boolean
|
|
752
|
+
- `config.attributeQuery` (AttributeQueryData, required): Attribute query configuration
|
|
753
|
+
|
|
754
|
+
**Examples**
|
|
755
|
+
```typescript
|
|
756
|
+
// Check if user has any pending tasks
|
|
757
|
+
const hasPendingTasks = Property.create({
|
|
758
|
+
name: 'hasPendingTasks',
|
|
759
|
+
type: 'boolean',
|
|
760
|
+
computation: Any.create({
|
|
761
|
+
property: 'tasks', // Use property name from relation
|
|
762
|
+
attributeQuery: [['&', {attributeQuery: ['status']}]], // Access relation's own property with '&'
|
|
763
|
+
callback: function(taskRelation) {
|
|
764
|
+
return taskRelation['&'].status === 'pending'
|
|
765
|
+
}
|
|
766
|
+
})
|
|
767
|
+
})
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
### Transform.create()
|
|
771
|
+
|
|
772
|
+
Create custom transformation computation.
|
|
773
|
+
|
|
774
|
+
Transform is fundamentally about **transforming data from one collection to another collection**. It transforms sets of data (e.g., InteractionEventEntity → Entity/Relation, Entity → different Entity). Transform **cannot** be used for property computations within the same entity - use `getValue` for that purpose.
|
|
775
|
+
|
|
776
|
+
**Important: One-to-Many Transformations**
|
|
777
|
+
Transform callbacks can return either:
|
|
778
|
+
- A single object → creates one record
|
|
779
|
+
- An array of objects → creates multiple records from a single source
|
|
780
|
+
- `null` or `undefined` → creates no records
|
|
781
|
+
|
|
782
|
+
**Syntax**
|
|
783
|
+
```typescript
|
|
784
|
+
Transform.create(config: TransformConfig): TransformInstance
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
**Parameters**
|
|
788
|
+
Transform supports two modes of operation:
|
|
789
|
+
|
|
790
|
+
1. **Entity/Relation Transform Mode**:
|
|
791
|
+
- `config.record` (Entity|Relation, required): Entity or relation to transform from (source collection)
|
|
792
|
+
- `config.attributeQuery` (AttributeQueryData, optional): Attribute query configuration
|
|
793
|
+
- `config.callback` (function, required): Transformation function that converts source data to target data
|
|
794
|
+
- **Context**: `this` is bound to the Controller instance, providing access to system APIs via `this.system.storage`, `this.globals`, etc.
|
|
795
|
+
- **Signature**: `function(this: Controller, record: any): any | any[]`
|
|
796
|
+
|
|
797
|
+
2. **Event-Driven Transform Mode** (Recommended for interaction-based transformations):
|
|
798
|
+
- `config.eventDeps` (EventDeps, required): Event dependencies that trigger the transformation
|
|
799
|
+
- `config.callback` (function, required): Transformation function that processes mutation events
|
|
800
|
+
- **Context**: `this` is bound to the Controller instance, providing access to system APIs via `this.system.storage`, `this.globals`, etc.
|
|
801
|
+
- **Signature**: `function(this: Controller, mutationEvent: MutationEvent): any | any[]`
|
|
802
|
+
|
|
803
|
+
**Event Dependencies (eventDeps)**
|
|
804
|
+
|
|
805
|
+
The `eventDeps` parameter allows Transform to react to specific mutation events (create, update, delete) on entities. This is the recommended approach for transforming data based on interactions or system events.
|
|
806
|
+
|
|
807
|
+
```typescript
|
|
808
|
+
interface EventDep {
|
|
809
|
+
recordName: string; // Entity name to listen to
|
|
810
|
+
type: 'create' | 'update' | 'delete'; // Event type
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Example eventDeps structure
|
|
814
|
+
eventDeps: {
|
|
815
|
+
UserCreate: {
|
|
816
|
+
recordName: 'User',
|
|
817
|
+
type: 'create'
|
|
818
|
+
},
|
|
819
|
+
UserUpdate: {
|
|
820
|
+
recordName: 'User',
|
|
821
|
+
type: 'update'
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
**Examples**
|
|
827
|
+
|
|
828
|
+
1. **Entity-to-Entity Transform** (for deriving new entities from existing ones):
|
|
829
|
+
```typescript
|
|
830
|
+
// Create discounted products from regular products
|
|
831
|
+
const DiscountedProduct = Entity.create({
|
|
832
|
+
name: 'DiscountedProduct',
|
|
833
|
+
properties: [
|
|
834
|
+
Property.create({ name: 'name', type: 'string' }),
|
|
835
|
+
Property.create({ name: 'originalPrice', type: 'number' }),
|
|
836
|
+
Property.create({ name: 'discountedPrice', type: 'number' })
|
|
837
|
+
],
|
|
838
|
+
computation: Transform.create({
|
|
839
|
+
record: Product,
|
|
840
|
+
attributeQuery: ['name', 'price'],
|
|
841
|
+
callback: async function(this: Controller, product) {
|
|
842
|
+
// Access system configuration via Controller
|
|
843
|
+
const discountRate = await this.system.storage.get('config', 'globalDiscountRate', 0.1);
|
|
844
|
+
|
|
845
|
+
return {
|
|
846
|
+
name: product.name,
|
|
847
|
+
originalPrice: product.price,
|
|
848
|
+
discountedPrice: product.price * (1 - discountRate)
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
})
|
|
852
|
+
})
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
2. **Event-Driven Transform** (recommended for interaction-based transformations):
|
|
856
|
+
```typescript
|
|
857
|
+
// Create audit logs from user mutations
|
|
858
|
+
const UserAudit = Entity.create({
|
|
859
|
+
name: 'UserAudit',
|
|
860
|
+
properties: [
|
|
861
|
+
Property.create({ name: 'action', type: 'string' }),
|
|
862
|
+
Property.create({ name: 'userId', type: 'string' }),
|
|
863
|
+
Property.create({ name: 'timestamp', type: 'string' }),
|
|
864
|
+
Property.create({ name: 'changes', type: 'object' })
|
|
865
|
+
],
|
|
866
|
+
computation: Transform.create({
|
|
867
|
+
eventDeps: {
|
|
868
|
+
UserCreate: {
|
|
869
|
+
recordName: 'User',
|
|
870
|
+
type: 'create'
|
|
871
|
+
},
|
|
872
|
+
UserUpdate: {
|
|
873
|
+
recordName: 'User',
|
|
874
|
+
type: 'update'
|
|
875
|
+
},
|
|
876
|
+
UserDelete: {
|
|
877
|
+
recordName: 'User',
|
|
878
|
+
type: 'delete'
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
callback: function(this: Controller, mutationEvent) {
|
|
882
|
+
// Access Controller APIs via 'this'
|
|
883
|
+
console.log('Audit log created by:', this.name); // Controller name
|
|
884
|
+
|
|
885
|
+
return {
|
|
886
|
+
action: mutationEvent.type,
|
|
887
|
+
userId: (mutationEvent.record?.id || mutationEvent.oldRecord?.id),
|
|
888
|
+
timestamp: new Date().toISOString(),
|
|
889
|
+
changes: {
|
|
890
|
+
old: mutationEvent.oldRecord,
|
|
891
|
+
new: mutationEvent.record
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
})
|
|
896
|
+
})
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
3. **Transform from Interaction Events**:
|
|
900
|
+
```typescript
|
|
901
|
+
// Create articles from interactions
|
|
902
|
+
const Article = Entity.create({
|
|
903
|
+
name: 'Article',
|
|
904
|
+
properties: [
|
|
905
|
+
Property.create({ name: 'title', type: 'string' }),
|
|
906
|
+
Property.create({ name: 'content', type: 'string' }),
|
|
907
|
+
Property.create({ name: 'authorId', type: 'string' }),
|
|
908
|
+
Property.create({ name: 'authorName', type: 'string' })
|
|
909
|
+
],
|
|
910
|
+
computation: Transform.create({
|
|
911
|
+
eventDeps: {
|
|
912
|
+
ArticleInteraction: {
|
|
913
|
+
recordName: InteractionEventEntity.name,
|
|
914
|
+
type: 'create'
|
|
915
|
+
}
|
|
916
|
+
},
|
|
917
|
+
callback: async function(this: Controller, mutationEvent) {
|
|
918
|
+
const event = mutationEvent.record;
|
|
919
|
+
if (event.interactionName === 'CreateArticle') {
|
|
920
|
+
// Use Controller to fetch additional user data
|
|
921
|
+
const author = await this.system.storage.findOne('User',
|
|
922
|
+
this.globals.MatchExp.atom({ key: 'id', value: ['=', event.user.id] }),
|
|
923
|
+
undefined,
|
|
924
|
+
['id', 'name']
|
|
925
|
+
);
|
|
926
|
+
|
|
927
|
+
return {
|
|
928
|
+
title: event.payload.title,
|
|
929
|
+
content: event.payload.content,
|
|
930
|
+
authorId: event.user.id,
|
|
931
|
+
authorName: author?.name || 'Unknown'
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
})
|
|
937
|
+
})
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
4. **One-to-Many Transform**:
|
|
941
|
+
```typescript
|
|
942
|
+
// Create multiple notifications from one order
|
|
943
|
+
const Notification = Entity.create({
|
|
944
|
+
name: 'Notification',
|
|
945
|
+
properties: [
|
|
946
|
+
Property.create({ name: 'type', type: 'string' }),
|
|
947
|
+
Property.create({ name: 'recipient', type: 'string' }),
|
|
948
|
+
Property.create({ name: 'message', type: 'string' })
|
|
949
|
+
],
|
|
950
|
+
computation: Transform.create({
|
|
951
|
+
eventDeps: {
|
|
952
|
+
OrderCreate: {
|
|
953
|
+
recordName: 'Order',
|
|
954
|
+
type: 'create'
|
|
955
|
+
}
|
|
956
|
+
},
|
|
957
|
+
callback: async function(this: Controller, mutationEvent) {
|
|
958
|
+
const order = mutationEvent.record;
|
|
959
|
+
|
|
960
|
+
// Use Controller to fetch warehouse email from configuration
|
|
961
|
+
const warehouseEmail = await this.system.storage.get('config', 'warehouseEmail', 'warehouse@company.com');
|
|
962
|
+
|
|
963
|
+
// Use Controller to check if customer wants notifications
|
|
964
|
+
const customer = await this.system.storage.findOne('User',
|
|
965
|
+
this.globals.MatchExp.atom({ key: 'email', value: ['=', order.customerEmail] }),
|
|
966
|
+
undefined,
|
|
967
|
+
['id', 'notificationPreferences']
|
|
968
|
+
);
|
|
969
|
+
|
|
970
|
+
const notifications = [];
|
|
971
|
+
|
|
972
|
+
// Only send customer notification if they opted in
|
|
973
|
+
if (customer?.notificationPreferences?.orderUpdates !== false) {
|
|
974
|
+
notifications.push({
|
|
975
|
+
type: 'order_confirmation',
|
|
976
|
+
recipient: order.customerEmail,
|
|
977
|
+
message: `Order ${order.orderNumber} confirmed`
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Always notify warehouse
|
|
982
|
+
notifications.push({
|
|
983
|
+
type: 'warehouse_notification',
|
|
984
|
+
recipient: warehouseEmail,
|
|
985
|
+
message: `New order ${order.orderNumber} to process`
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
return notifications;
|
|
989
|
+
}
|
|
990
|
+
})
|
|
991
|
+
})
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
**Key Points**
|
|
995
|
+
|
|
996
|
+
1. **Return Types**:
|
|
997
|
+
- Single object: Creates one target record
|
|
998
|
+
- Array of objects: Creates multiple target records from one source
|
|
999
|
+
- `null`/`undefined`: Creates no records (useful for filtering)
|
|
1000
|
+
|
|
1001
|
+
2. **Automatic Updates**:
|
|
1002
|
+
- For entity/relation transforms: When source records are updated or deleted, the transformed records are automatically updated or removed
|
|
1003
|
+
- For event-driven transforms: New records are created in response to mutation events
|
|
1004
|
+
|
|
1005
|
+
3. **Choosing Between Modes**:
|
|
1006
|
+
- Use `record` mode when deriving entities from other entities (e.g., creating views or projections)
|
|
1007
|
+
- Use `eventDeps` mode when creating entities in response to system events or interactions
|
|
1008
|
+
|
|
1009
|
+
4. **MutationEvent Structure** (for eventDeps callbacks):
|
|
1010
|
+
```typescript
|
|
1011
|
+
{
|
|
1012
|
+
type: 'create' | 'update' | 'delete',
|
|
1013
|
+
recordName: string,
|
|
1014
|
+
record?: any, // New/current record (for create/update)
|
|
1015
|
+
oldRecord?: any // Previous record (for update/delete)
|
|
1016
|
+
}
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
### StateMachine.create()
|
|
1020
|
+
|
|
1021
|
+
Create state machine computation that manages state transitions and computes values based on those transitions. Can be used for entity properties, relation properties, or global dictionaries.
|
|
1022
|
+
|
|
1023
|
+
**Syntax**
|
|
1024
|
+
```typescript
|
|
1025
|
+
StateMachine.create(config: StateMachineConfig): StateMachineInstance
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
**Parameters**
|
|
1029
|
+
- `config.states` (StateNode[], required): List of state nodes
|
|
1030
|
+
- `config.transfers` (StateTransfer[], required): List of state transfers
|
|
1031
|
+
- `config.defaultState` (StateNode, required): Default state
|
|
1032
|
+
|
|
1033
|
+
**Important: Initial Value Handling**
|
|
1034
|
+
|
|
1035
|
+
When using StateMachine for entity/relation properties:
|
|
1036
|
+
- If the property's initial value is set when the entity/relation is created, handle it in the entity/relation's computation
|
|
1037
|
+
- If the StateMachine needs to save or modify this initial value, explicitly define `computeValue` on the `defaultState` to handle it
|
|
1038
|
+
|
|
1039
|
+
```typescript
|
|
1040
|
+
// Example: StateMachine handling initial value
|
|
1041
|
+
const MyStateMachine = StateMachine.create({
|
|
1042
|
+
states: [pendingState, activeState],
|
|
1043
|
+
transfers: [...],
|
|
1044
|
+
defaultState: StateNode.create({
|
|
1045
|
+
name: 'pending',
|
|
1046
|
+
computeValue: (lastValue, mutationEvent) => {
|
|
1047
|
+
// Explicitly handle initial value
|
|
1048
|
+
return lastValue || 'initial_state_value';
|
|
1049
|
+
}
|
|
1050
|
+
})
|
|
1051
|
+
});
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
**Examples**
|
|
1055
|
+
```typescript
|
|
1056
|
+
// First declare state nodes
|
|
1057
|
+
const pendingState = StateNode.create({ name: 'pending' });
|
|
1058
|
+
const confirmedState = StateNode.create({ name: 'confirmed' });
|
|
1059
|
+
const shippedState = StateNode.create({ name: 'shipped' });
|
|
1060
|
+
const deliveredState = StateNode.create({ name: 'delivered' });
|
|
1061
|
+
|
|
1062
|
+
const OrderStateMachine = StateMachine.create({
|
|
1063
|
+
states: [pendingState, confirmedState, shippedState, deliveredState],
|
|
1064
|
+
transfers: [
|
|
1065
|
+
StateTransfer.create({
|
|
1066
|
+
current: pendingState,
|
|
1067
|
+
next: confirmedState,
|
|
1068
|
+
trigger: {
|
|
1069
|
+
recordName: InteractionEventEntity.name,
|
|
1070
|
+
record: {
|
|
1071
|
+
interactionName: ConfirmOrderInteraction.name
|
|
1072
|
+
}
|
|
1073
|
+
},
|
|
1074
|
+
computeTarget: (mutationEvent) => ({ id: mutationEvent.record.payload.orderId })
|
|
1075
|
+
})
|
|
1076
|
+
],
|
|
1077
|
+
defaultState: pendingState
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
// Relation with HardDeletionProperty for deletion management
|
|
1081
|
+
const UserTargetRelation = Relation.create({
|
|
1082
|
+
source: User,
|
|
1083
|
+
sourceProperty: 'targets',
|
|
1084
|
+
target: TargetEntity,
|
|
1085
|
+
targetProperty: 'users',
|
|
1086
|
+
type: 'n:n',
|
|
1087
|
+
properties: [
|
|
1088
|
+
Property.create({ name: 'createdAt', type: 'string' }),
|
|
1089
|
+
Property.create({ name: 'isActive', type: 'boolean' }),
|
|
1090
|
+
],
|
|
1091
|
+
// Use Transform to create relations
|
|
1092
|
+
computation: Transform.create({
|
|
1093
|
+
eventDeps: {
|
|
1094
|
+
RelationInteraction: {
|
|
1095
|
+
recordName: InteractionEventEntity.name,
|
|
1096
|
+
type: 'create'
|
|
1097
|
+
}
|
|
1098
|
+
},
|
|
1099
|
+
callback: async function(this: Controller, mutationEvent) {
|
|
1100
|
+
const event = mutationEvent.record;
|
|
1101
|
+
if (event.interactionName === 'CreateRelation') {
|
|
1102
|
+
// Use Controller to validate the relation
|
|
1103
|
+
const sourceExists = await this.system.storage.findOne('User',
|
|
1104
|
+
MatchExp.atom({ key: 'id', value: ['=', event.payload.sourceUser.id] }),
|
|
1105
|
+
undefined,
|
|
1106
|
+
['id']
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
if (!sourceExists) {
|
|
1110
|
+
console.log('Source user not found, skipping relation creation');
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return {
|
|
1115
|
+
source: event.payload.sourceUser,
|
|
1116
|
+
target: event.payload.targetEntity,
|
|
1117
|
+
createdAt: new Date().toISOString(),
|
|
1118
|
+
isActive: true
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return null
|
|
1122
|
+
}
|
|
1123
|
+
})
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
const relationDeletionProperty = HardDeletionProperty.create()
|
|
1127
|
+
|
|
1128
|
+
// Configure deletion for relation's HardDeletionProperty
|
|
1129
|
+
relationDeletionProperty.computation = StateMachine.create({
|
|
1130
|
+
states: [NON_DELETED_STATE, DELETED_STATE],
|
|
1131
|
+
defaultState: NON_DELETED_STATE,
|
|
1132
|
+
transfers: [
|
|
1133
|
+
StateTransfer.create({
|
|
1134
|
+
trigger: {
|
|
1135
|
+
recordName: InteractionEventEntity.name,
|
|
1136
|
+
record: {
|
|
1137
|
+
interactionName: DeleteRelationInteraction.name
|
|
1138
|
+
}
|
|
1139
|
+
},
|
|
1140
|
+
current: NON_DELETED_STATE,
|
|
1141
|
+
next: DELETED_STATE,
|
|
1142
|
+
computeTarget: async function(this: Controller, mutationEvent: any) {
|
|
1143
|
+
const MatchExp = this.globals.MatchExp;
|
|
1144
|
+
const relation = await this.system.storage.findOne(
|
|
1145
|
+
UserTargetRelation.name,
|
|
1146
|
+
MatchExp.atom({
|
|
1147
|
+
key: 'source.id',
|
|
1148
|
+
value: ['=', mutationEvent.record.payload.sourceId]
|
|
1149
|
+
}).and({
|
|
1150
|
+
key: 'target.id',
|
|
1151
|
+
value: ['=', mutationEvent.record.payload.targetId]
|
|
1152
|
+
}),
|
|
1153
|
+
undefined,
|
|
1154
|
+
['id']
|
|
1155
|
+
);
|
|
1156
|
+
return relation ? { id: relation.id } : undefined;
|
|
1157
|
+
}
|
|
1158
|
+
})
|
|
1159
|
+
]
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
UserTargetRelation.properties.push(relationDeletionProperty)
|
|
1163
|
+
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
### RealTime.create()
|
|
1167
|
+
|
|
1168
|
+
Create real-time computation for handling time-based reactive computations. Real-time computations automatically manage state (lastRecomputeTime and nextRecomputeTime) and adopt different scheduling strategies based on return type.
|
|
1169
|
+
|
|
1170
|
+
**Syntax**
|
|
1171
|
+
```typescript
|
|
1172
|
+
RealTime.create(config: RealTimeConfig): RealTimeInstance
|
|
1173
|
+
```
|
|
1174
|
+
|
|
1175
|
+
**Parameters**
|
|
1176
|
+
- `config.callback` (function, required): Real-time computation callback function, accepts `(now: Expression, dataDeps: any) => Expression | Inequality | Equation`
|
|
1177
|
+
- `config.nextRecomputeTime` (function, optional): Recomputation interval function, accepts `(now: number, dataDeps: any) => number`, only valid for Expression type
|
|
1178
|
+
- `config.dataDeps` (object, optional): Data dependency configuration, format: `{[key: string]: DataDep}`
|
|
1179
|
+
- `config.attributeQuery` (AttributeQueryData, optional): Attribute query configuration
|
|
1180
|
+
|
|
1181
|
+
**Return Types and Scheduling Behavior**
|
|
1182
|
+
- **Expression**: Returns numeric computation result, nextRecomputeTime = lastRecomputeTime + nextRecomputeTime function return value
|
|
1183
|
+
- **Inequality**: Returns boolean comparison result, nextRecomputeTime = solve() result (critical time point for state change)
|
|
1184
|
+
- **Equation**: Returns boolean equation result, nextRecomputeTime = solve() result (critical time point for state change)
|
|
1185
|
+
|
|
1186
|
+
**State Management**
|
|
1187
|
+
|
|
1188
|
+
RealTime computations automatically create and manage two state fields:
|
|
1189
|
+
- `lastRecomputeTime`: Timestamp of last computation
|
|
1190
|
+
- `nextRecomputeTime`: Timestamp of next computation
|
|
1191
|
+
|
|
1192
|
+
State field naming convention:
|
|
1193
|
+
- Global computations: `_global_boundState_{computationName}_{stateName}`
|
|
1194
|
+
- Property computations: `_record_boundState_{entityName}_{propertyName}_{stateName}`
|
|
1195
|
+
|
|
1196
|
+
**Examples**
|
|
1197
|
+
|
|
1198
|
+
```typescript
|
|
1199
|
+
// Expression type: manually specify recomputation interval
|
|
1200
|
+
const currentTimestamp = Dictionary.create({
|
|
1201
|
+
name: 'currentTimestamp',
|
|
1202
|
+
type: 'number',
|
|
1203
|
+
computation: RealTime.create({
|
|
1204
|
+
nextRecomputeTime: (now: number, dataDeps: any) => 1000, // Update every second
|
|
1205
|
+
callback: async (now: Expression, dataDeps: any) => {
|
|
1206
|
+
return now.divide(1000); // Convert to seconds
|
|
1207
|
+
}
|
|
1208
|
+
})
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
// Inequality type: system automatically calculates critical time points
|
|
1212
|
+
const isAfterDeadline = Dictionary.create({
|
|
1213
|
+
name: 'isAfterDeadline',
|
|
1214
|
+
type: 'boolean',
|
|
1215
|
+
computation: RealTime.create({
|
|
1216
|
+
dataDeps: {
|
|
1217
|
+
project: {
|
|
1218
|
+
type: 'records',
|
|
1219
|
+
source: projectEntity,
|
|
1220
|
+
attributeQuery: ['deadline']
|
|
1221
|
+
}
|
|
1222
|
+
},
|
|
1223
|
+
callback: async (now: Expression, dataDeps: any) => {
|
|
1224
|
+
const deadline = dataDeps.project?.[0]?.deadline || Date.now() + 86400000;
|
|
1225
|
+
// System will automatically recompute at deadline time
|
|
1226
|
+
return now.gt(deadline);
|
|
1227
|
+
}
|
|
1228
|
+
})
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
// Equation type: check time equations
|
|
1232
|
+
const isExactHour = Dictionary.create({
|
|
1233
|
+
name: 'isExactHour',
|
|
1234
|
+
type: 'boolean',
|
|
1235
|
+
computation: RealTime.create({
|
|
1236
|
+
callback: async (now: Expression, dataDeps: any) => {
|
|
1237
|
+
const millisecondsInHour = 3600000;
|
|
1238
|
+
// System will automatically recompute at next exact hour
|
|
1239
|
+
return now.modulo(millisecondsInHour).eq(0);
|
|
1240
|
+
}
|
|
1241
|
+
})
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
// Property-level real-time computation
|
|
1245
|
+
const userEntity = Entity.create({
|
|
1246
|
+
name: 'User',
|
|
1247
|
+
properties: [
|
|
1248
|
+
Property.create({ name: 'lastLoginAt', type: 'number' }),
|
|
1249
|
+
Property.create({
|
|
1250
|
+
name: 'isRecentlyActive',
|
|
1251
|
+
type: 'boolean',
|
|
1252
|
+
computation: RealTime.create({
|
|
1253
|
+
dataDeps: {
|
|
1254
|
+
_current: {
|
|
1255
|
+
type: 'property',
|
|
1256
|
+
attributeQuery: ['lastLoginAt']
|
|
1257
|
+
}
|
|
1258
|
+
},
|
|
1259
|
+
callback: async (now: Expression, dataDeps: any) => {
|
|
1260
|
+
const lastLogin = dataDeps._current?.lastLoginAt || 0;
|
|
1261
|
+
const oneHourAgo = now.subtract(3600000);
|
|
1262
|
+
return Expression.number(lastLogin).gt(oneHourAgo);
|
|
1263
|
+
}
|
|
1264
|
+
})
|
|
1265
|
+
})
|
|
1266
|
+
]
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
// Complex data dependencies real-time computation
|
|
1270
|
+
const businessMetrics = Dictionary.create({
|
|
1271
|
+
name: 'businessMetrics',
|
|
1272
|
+
type: 'object',
|
|
1273
|
+
computation: RealTime.create({
|
|
1274
|
+
nextRecomputeTime: (now: number, dataDeps: any) => 300000, // Update every 5 minutes
|
|
1275
|
+
dataDeps: {
|
|
1276
|
+
config: {
|
|
1277
|
+
type: 'records',
|
|
1278
|
+
source: configEntity,
|
|
1279
|
+
attributeQuery: ['businessHourStart', 'businessHourEnd']
|
|
1280
|
+
},
|
|
1281
|
+
metrics: {
|
|
1282
|
+
type: 'records',
|
|
1283
|
+
source: metricsEntity,
|
|
1284
|
+
attributeQuery: ['dailyTarget', 'currentValue']
|
|
1285
|
+
}
|
|
1286
|
+
},
|
|
1287
|
+
callback: async (now: Expression, dataDeps: any) => {
|
|
1288
|
+
const config = dataDeps.config?.[0] || {};
|
|
1289
|
+
const metrics = dataDeps.metrics?.[0] || {};
|
|
1290
|
+
|
|
1291
|
+
const startHour = config.businessHourStart || 9;
|
|
1292
|
+
const endHour = config.businessHourEnd || 17;
|
|
1293
|
+
const currentHour = now.divide(3600000).modulo(24);
|
|
1294
|
+
|
|
1295
|
+
const isBusinessTime = currentHour.gt(startHour).and(currentHour.lt(endHour));
|
|
1296
|
+
const progressRate = Expression.number(metrics.currentValue || 0).divide(metrics.dailyTarget || 1);
|
|
1297
|
+
|
|
1298
|
+
return {
|
|
1299
|
+
isBusinessTime: isBusinessTime.evaluate({now: Date.now()}),
|
|
1300
|
+
progressRate: progressRate.evaluate({now: Date.now()}),
|
|
1301
|
+
timestamp: now.evaluate({now: Date.now()})
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
})
|
|
1305
|
+
});
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
**State Access Example**
|
|
1309
|
+
|
|
1310
|
+
```typescript
|
|
1311
|
+
// Get computation instance
|
|
1312
|
+
const realTimeComputation = Array.from(controller.scheduler.computations.values()).find(
|
|
1313
|
+
computation => computation.dataContext.type === 'global' &&
|
|
1314
|
+
computation.dataContext.id === 'currentTimestamp'
|
|
1315
|
+
);
|
|
1316
|
+
|
|
1317
|
+
// Get state key names
|
|
1318
|
+
const lastRecomputeTimeKey = controller.scheduler.getBoundStateName(
|
|
1319
|
+
realTimeComputation.dataContext,
|
|
1320
|
+
'lastRecomputeTime',
|
|
1321
|
+
realTimeComputation.state.lastRecomputeTime
|
|
1322
|
+
);
|
|
1323
|
+
|
|
1324
|
+
const nextRecomputeTimeKey = controller.scheduler.getBoundStateName(
|
|
1325
|
+
realTimeComputation.dataContext,
|
|
1326
|
+
'nextRecomputeTime',
|
|
1327
|
+
realTimeComputation.state.nextRecomputeTime
|
|
1328
|
+
);
|
|
1329
|
+
|
|
1330
|
+
// Read state values
|
|
1331
|
+
const lastRecomputeTime = await system.storage.dict.get(lastRecomputeTimeKey);
|
|
1332
|
+
const nextRecomputeTime = await system.storage.dict.get(nextRecomputeTimeKey);
|
|
1333
|
+
```
|
|
1334
|
+
|
|
1335
|
+
### Custom.create()
|
|
1336
|
+
|
|
1337
|
+
Create custom computation with completely user-defined calculation logic.
|
|
1338
|
+
|
|
1339
|
+
Custom computation provides maximum flexibility, allowing developers to implement any business logic that doesn't fit into other computation types. It supports custom compute functions, incremental computation, state management, and flexible data dependencies.
|
|
1340
|
+
|
|
1341
|
+
**Syntax**
|
|
1342
|
+
```typescript
|
|
1343
|
+
Custom.create(config: CustomConfig): CustomInstance
|
|
1344
|
+
```
|
|
1345
|
+
|
|
1346
|
+
**Parameters**
|
|
1347
|
+
- `config.name` (string, required): Computation name for identification
|
|
1348
|
+
- `config.compute` (function, required): Main computation function with signature:
|
|
1349
|
+
```typescript
|
|
1350
|
+
// For Dictionary/Global context:
|
|
1351
|
+
async function(
|
|
1352
|
+
this: { controller: Controller, state: any },
|
|
1353
|
+
dataDeps: any
|
|
1354
|
+
): Promise<any>
|
|
1355
|
+
|
|
1356
|
+
// For Property context:
|
|
1357
|
+
async function(
|
|
1358
|
+
this: { controller: Controller, state: any },
|
|
1359
|
+
dataDeps: any,
|
|
1360
|
+
record: any
|
|
1361
|
+
): Promise<any>
|
|
1362
|
+
```
|
|
1363
|
+
- `config.incrementalCompute` (function, optional): Incremental computation function for optimized updates:
|
|
1364
|
+
```typescript
|
|
1365
|
+
async function(
|
|
1366
|
+
this: { controller: Controller, state: any },
|
|
1367
|
+
lastValue: any,
|
|
1368
|
+
mutationEvent: any,
|
|
1369
|
+
record: any,
|
|
1370
|
+
dataDeps: any
|
|
1371
|
+
): Promise<any>
|
|
1372
|
+
```
|
|
1373
|
+
- `config.incrementalPatchCompute` (function, optional): Patch-based incremental computation:
|
|
1374
|
+
```typescript
|
|
1375
|
+
async function(
|
|
1376
|
+
this: { controller: Controller, state: any },
|
|
1377
|
+
lastValue: any,
|
|
1378
|
+
mutationEvent: any,
|
|
1379
|
+
record: any,
|
|
1380
|
+
dataDeps: any
|
|
1381
|
+
): Promise<ComputationResultPatch[]>
|
|
1382
|
+
```
|
|
1383
|
+
- `config.createState` (function, optional): Create custom state for the computation:
|
|
1384
|
+
```typescript
|
|
1385
|
+
function(): any
|
|
1386
|
+
```
|
|
1387
|
+
- `config.getDefaultValue` (function, optional): Provide default value when computation hasn't run:
|
|
1388
|
+
```typescript
|
|
1389
|
+
function(): any
|
|
1390
|
+
```
|
|
1391
|
+
- `config.asyncReturn` (function, optional): Handle async task results:
|
|
1392
|
+
```typescript
|
|
1393
|
+
async function(
|
|
1394
|
+
this: { controller: Controller, state: any },
|
|
1395
|
+
asyncResult: any,
|
|
1396
|
+
dataDeps: any,
|
|
1397
|
+
record?: any
|
|
1398
|
+
): Promise<any>
|
|
1399
|
+
```
|
|
1400
|
+
- `config.dataDeps` (object, optional): Data dependency configuration, format: `{[key: string]: DataDep}`.
|
|
1401
|
+
- For Property computation: use `type: 'property'` with `attributeQuery` to access current record and its relations
|
|
1402
|
+
- For Dictionary computation: use `type: 'records'` with `source: EntityName`
|
|
1403
|
+
- For accessing dictionaries: use `type: 'global'` with `source: DictionaryInstance`
|
|
1404
|
+
- `config.useLastValue` (boolean, optional): Whether to use last computed value in incremental computation
|
|
1405
|
+
- `config.attributeQuery` (AttributeQueryData, optional): Attribute query configuration
|
|
1406
|
+
|
|
1407
|
+
**Examples**
|
|
1408
|
+
|
|
1409
|
+
```typescript
|
|
1410
|
+
// Basic custom computation for global dictionary
|
|
1411
|
+
const userStats = Dictionary.create({
|
|
1412
|
+
name: 'userStats',
|
|
1413
|
+
type: 'object',
|
|
1414
|
+
collection: false,
|
|
1415
|
+
computation: Custom.create({
|
|
1416
|
+
name: 'UserStatsCalculator',
|
|
1417
|
+
dataDeps: {
|
|
1418
|
+
users: {
|
|
1419
|
+
type: 'records',
|
|
1420
|
+
source: User,
|
|
1421
|
+
attributeQuery: ['id', 'status', 'createdAt']
|
|
1422
|
+
},
|
|
1423
|
+
posts: {
|
|
1424
|
+
type: 'records',
|
|
1425
|
+
source: Post,
|
|
1426
|
+
attributeQuery: ['id', 'authorId', 'status']
|
|
1427
|
+
}
|
|
1428
|
+
},
|
|
1429
|
+
compute: async function(this: Controller, dataContext, args, state, dataDeps) {
|
|
1430
|
+
const activeUsers = dataDeps.users.filter(u => u.status === 'active');
|
|
1431
|
+
const publishedPosts = dataDeps.posts.filter(p => p.status === 'published');
|
|
1432
|
+
|
|
1433
|
+
return {
|
|
1434
|
+
totalUsers: dataDeps.users.length,
|
|
1435
|
+
activeUsers: activeUsers.length,
|
|
1436
|
+
totalPosts: dataDeps.posts.length,
|
|
1437
|
+
publishedPosts: publishedPosts.length,
|
|
1438
|
+
avgPostsPerUser: dataDeps.posts.length / dataDeps.users.length || 0
|
|
1439
|
+
};
|
|
1440
|
+
},
|
|
1441
|
+
getDefaultValue: function() {
|
|
1442
|
+
return {
|
|
1443
|
+
totalUsers: 0,
|
|
1444
|
+
activeUsers: 0,
|
|
1445
|
+
totalPosts: 0,
|
|
1446
|
+
publishedPosts: 0,
|
|
1447
|
+
avgPostsPerUser: 0
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
})
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
// Incremental computation with state management
|
|
1454
|
+
const counterDict = Dictionary.create({
|
|
1455
|
+
name: 'globalCounter',
|
|
1456
|
+
type: 'object',
|
|
1457
|
+
collection: false,
|
|
1458
|
+
computation: Custom.create({
|
|
1459
|
+
name: 'IncrementalCounter',
|
|
1460
|
+
useLastValue: true,
|
|
1461
|
+
dataDeps: {
|
|
1462
|
+
counters: {
|
|
1463
|
+
type: 'records',
|
|
1464
|
+
source: Counter,
|
|
1465
|
+
attributeQuery: ['value']
|
|
1466
|
+
}
|
|
1467
|
+
},
|
|
1468
|
+
compute: async function(dataDeps) {
|
|
1469
|
+
const total = dataDeps.counters.reduce((sum, c) => sum + (c.value || 0), 0);
|
|
1470
|
+
return { total, count: dataDeps.counters.length };
|
|
1471
|
+
},
|
|
1472
|
+
incrementalCompute: async function(lastValue, mutationEvent, record, dataDeps) {
|
|
1473
|
+
console.log('Previous value:', lastValue);
|
|
1474
|
+
const total = dataDeps.counters.reduce((sum, c) => sum + (c.value || 0), 0);
|
|
1475
|
+
const diff = total - (lastValue?.total || 0);
|
|
1476
|
+
|
|
1477
|
+
return {
|
|
1478
|
+
total,
|
|
1479
|
+
count: dataDeps.counters.length,
|
|
1480
|
+
lastChange: diff,
|
|
1481
|
+
timestamp: Math.floor(Date.now()/1000) // In seconds
|
|
1482
|
+
};
|
|
1483
|
+
},
|
|
1484
|
+
getDefaultValue: function() {
|
|
1485
|
+
return { total: 0, count: 0, lastChange: 0 };
|
|
1486
|
+
}
|
|
1487
|
+
})
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
// Custom computation with persistent state
|
|
1491
|
+
const stateManager = Dictionary.create({
|
|
1492
|
+
name: 'stateManager',
|
|
1493
|
+
type: 'object',
|
|
1494
|
+
defaultValue: () => ({ value: 0 }),
|
|
1495
|
+
computation: Custom.create({
|
|
1496
|
+
name: 'StateManager',
|
|
1497
|
+
dataDeps: {
|
|
1498
|
+
trigger: {
|
|
1499
|
+
type: 'global',
|
|
1500
|
+
source: triggerDict
|
|
1501
|
+
}
|
|
1502
|
+
},
|
|
1503
|
+
createState: function() {
|
|
1504
|
+
return {
|
|
1505
|
+
persistentCounter: new GlobalBoundState({ count: 0 })
|
|
1506
|
+
};
|
|
1507
|
+
},
|
|
1508
|
+
compute: async function(dataDeps) {
|
|
1509
|
+
if (!this.state || !this.state.persistentCounter) {
|
|
1510
|
+
return { value: 0, error: 'no state' };
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Read current state
|
|
1514
|
+
const current = await this.state.persistentCounter.get() || { count: 0 };
|
|
1515
|
+
|
|
1516
|
+
// Update state based on trigger
|
|
1517
|
+
const increment = dataDeps.trigger || 0;
|
|
1518
|
+
const newState = { count: current.count + increment };
|
|
1519
|
+
await this.state.persistentCounter.set(newState);
|
|
1520
|
+
|
|
1521
|
+
return {
|
|
1522
|
+
value: newState.count,
|
|
1523
|
+
triggerValue: dataDeps.trigger
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
})
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
// Async custom computation
|
|
1530
|
+
const apiDataProcessor = Dictionary.create({
|
|
1531
|
+
name: 'apiData',
|
|
1532
|
+
type: 'object',
|
|
1533
|
+
collection: false,
|
|
1534
|
+
computation: Custom.create({
|
|
1535
|
+
name: 'APIDataProcessor',
|
|
1536
|
+
asyncReturn: true,
|
|
1537
|
+
dataDeps: {
|
|
1538
|
+
endpoints: {
|
|
1539
|
+
type: 'records',
|
|
1540
|
+
source: APIEndpoint,
|
|
1541
|
+
attributeQuery: ['url', 'method', 'headers']
|
|
1542
|
+
}
|
|
1543
|
+
},
|
|
1544
|
+
compute: async function(dataDeps) {
|
|
1545
|
+
// Return async task definition
|
|
1546
|
+
return {
|
|
1547
|
+
taskType: 'fetchAPI',
|
|
1548
|
+
endpoints: dataDeps.endpoints,
|
|
1549
|
+
processAt: Date.now()
|
|
1550
|
+
};
|
|
1551
|
+
},
|
|
1552
|
+
getDefaultValue: function() {
|
|
1553
|
+
return { status: 'pending', data: null };
|
|
1554
|
+
}
|
|
1555
|
+
})
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
// Complex relation-based computation (Dictionary level)
|
|
1559
|
+
const relationAnalyzer = Dictionary.create({
|
|
1560
|
+
name: 'relationStats',
|
|
1561
|
+
type: 'object',
|
|
1562
|
+
collection: true,
|
|
1563
|
+
computation: Custom.create({
|
|
1564
|
+
name: 'RelationAnalyzer',
|
|
1565
|
+
dataDeps: {
|
|
1566
|
+
posts: {
|
|
1567
|
+
type: 'records', // Global query for all posts
|
|
1568
|
+
source: Post,
|
|
1569
|
+
attributeQuery: ['id', 'title']
|
|
1570
|
+
},
|
|
1571
|
+
relations: {
|
|
1572
|
+
type: 'records', // Global query for all relations
|
|
1573
|
+
source: PostAuthorRelation,
|
|
1574
|
+
attributeQuery: ['source', 'target']
|
|
1575
|
+
}
|
|
1576
|
+
},
|
|
1577
|
+
compute: async function(dataDeps) {
|
|
1578
|
+
const result: any = {};
|
|
1579
|
+
|
|
1580
|
+
for (const post of dataDeps.posts || []) {
|
|
1581
|
+
const authorCount = (dataDeps.relations || []).filter(r =>
|
|
1582
|
+
r.source && r.source.id === post.id
|
|
1583
|
+
).length;
|
|
1584
|
+
|
|
1585
|
+
result[post.id] = {
|
|
1586
|
+
title: post.title,
|
|
1587
|
+
authorCount: authorCount
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
return result;
|
|
1592
|
+
}
|
|
1593
|
+
})
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
// Property-level custom computation accessing own properties
|
|
1597
|
+
const User = Entity.create({
|
|
1598
|
+
name: 'User',
|
|
1599
|
+
properties: [
|
|
1600
|
+
Property.create({ name: 'score', type: 'number' }),
|
|
1601
|
+
Property.create({
|
|
1602
|
+
name: 'level',
|
|
1603
|
+
type: 'string',
|
|
1604
|
+
defaultValue: () => 'beginner',
|
|
1605
|
+
computation: Custom.create({
|
|
1606
|
+
name: 'UserLevelCalculator',
|
|
1607
|
+
dataDeps: {
|
|
1608
|
+
self: {
|
|
1609
|
+
type: 'property',
|
|
1610
|
+
attributeQuery: ['score'] // Access current record's score property
|
|
1611
|
+
},
|
|
1612
|
+
levelConfig: {
|
|
1613
|
+
type: 'global',
|
|
1614
|
+
source: levelConfigDict // Global trigger required
|
|
1615
|
+
}
|
|
1616
|
+
},
|
|
1617
|
+
compute: async function(dataDeps, record) {
|
|
1618
|
+
const score = dataDeps.self?.score || 0;
|
|
1619
|
+
const config = dataDeps.levelConfig || {
|
|
1620
|
+
beginner: 0,
|
|
1621
|
+
intermediate: 100,
|
|
1622
|
+
expert: 500
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1625
|
+
if (score >= config.expert) return 'expert';
|
|
1626
|
+
if (score >= config.intermediate) return 'intermediate';
|
|
1627
|
+
return 'beginner';
|
|
1628
|
+
}
|
|
1629
|
+
})
|
|
1630
|
+
})
|
|
1631
|
+
]
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
// Property-level custom computation accessing related entities
|
|
1635
|
+
const Order = Entity.create({
|
|
1636
|
+
name: 'Order',
|
|
1637
|
+
properties: [
|
|
1638
|
+
Property.create({ name: 'orderId', type: 'string' }),
|
|
1639
|
+
Property.create({
|
|
1640
|
+
name: 'totalValue',
|
|
1641
|
+
type: 'number',
|
|
1642
|
+
defaultValue: () => 0,
|
|
1643
|
+
computation: Custom.create({
|
|
1644
|
+
name: 'OrderTotalCalculator',
|
|
1645
|
+
dataDeps: {
|
|
1646
|
+
orderData: {
|
|
1647
|
+
type: 'property',
|
|
1648
|
+
attributeQuery: [
|
|
1649
|
+
['items', { // Access related items through nested query
|
|
1650
|
+
attributeQuery: ['price', 'quantity', 'discount']
|
|
1651
|
+
}]
|
|
1652
|
+
]
|
|
1653
|
+
}
|
|
1654
|
+
},
|
|
1655
|
+
compute: async function(dataDeps, record) {
|
|
1656
|
+
const items = dataDeps.orderData?.items || [];
|
|
1657
|
+
return items.reduce((total, item) => {
|
|
1658
|
+
const price = item.price || 0;
|
|
1659
|
+
const quantity = item.quantity || 1;
|
|
1660
|
+
const discount = item.discount || 0;
|
|
1661
|
+
return total + (price * quantity * (1 - discount));
|
|
1662
|
+
}, 0);
|
|
1663
|
+
}
|
|
1664
|
+
})
|
|
1665
|
+
})
|
|
1666
|
+
]
|
|
1667
|
+
});
|
|
1668
|
+
```
|
|
1669
|
+
|
|
1670
|
+
**Advanced Features**
|
|
1671
|
+
|
|
1672
|
+
1. **State Management**: Use `createState()` to create persistent state that survives across computation cycles. State can be stored using `GlobalBoundState`, `EntityBoundState`, or `RelationBoundState`.
|
|
1673
|
+
|
|
1674
|
+
2. **Incremental Computation**: Implement `incrementalCompute` for optimized updates that can access the previous computed value.
|
|
1675
|
+
|
|
1676
|
+
3. **Patch-based Updates**: Use `incrementalPatchCompute` for fine-grained updates that return specific changes rather than full recomputation.
|
|
1677
|
+
|
|
1678
|
+
4. **Flexible Data Dependencies**: Configure complex data dependencies including:
|
|
1679
|
+
- `type: 'records'`: Fetch all entity/relation records globally (for Dictionary/global computations)
|
|
1680
|
+
- `type: 'property'`: Access current record's data including relations via nested attributeQuery (for Property computations)
|
|
1681
|
+
- `type: 'global'`: Access global dictionary values
|
|
1682
|
+
|
|
1683
|
+
**🔴 CRITICAL: dataDeps type Configuration**
|
|
1684
|
+
|
|
1685
|
+
**For Property-level Custom computation:**
|
|
1686
|
+
- Use `type: 'property'` to access the current record's data
|
|
1687
|
+
- Use nested attributeQuery to access related entities through relations
|
|
1688
|
+
- Example: If you have a UserPostRelation with `sourceProperty: 'posts'`, use:
|
|
1689
|
+
```typescript
|
|
1690
|
+
dataDeps: {
|
|
1691
|
+
currentRecord: {
|
|
1692
|
+
type: 'property',
|
|
1693
|
+
attributeQuery: [
|
|
1694
|
+
'id', 'name', // Current record's own properties
|
|
1695
|
+
['posts', { // Access related entities through nested query
|
|
1696
|
+
attributeQuery: ['title', 'status']
|
|
1697
|
+
}]
|
|
1698
|
+
]
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
// Then access in compute: dataDeps.currentRecord.posts
|
|
1702
|
+
```
|
|
1703
|
+
|
|
1704
|
+
**For Dictionary-level Custom computation:**
|
|
1705
|
+
- Use `type: 'records'` for global entity queries
|
|
1706
|
+
- Specify `source: EntityName` to query all records of that entity
|
|
1707
|
+
- Example:
|
|
1708
|
+
```typescript
|
|
1709
|
+
dataDeps: {
|
|
1710
|
+
users: {
|
|
1711
|
+
type: 'records', // Global query
|
|
1712
|
+
source: User,
|
|
1713
|
+
attributeQuery: ['id', 'status']
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
```
|
|
1717
|
+
|
|
1718
|
+
**Common mistake**: Using `type: 'records'` in Property computation won't work correctly - it won't access the related entities for the specific record!
|
|
1719
|
+
|
|
1720
|
+
5. **Async Support**: Set `asyncReturn: true` to return async task definitions that will be processed by the system's task queue.
|
|
1721
|
+
|
|
1722
|
+
**Best Practices**
|
|
1723
|
+
|
|
1724
|
+
1. **Always provide `getDefaultValue`**: This ensures the computation has a valid initial value
|
|
1725
|
+
2. **Use appropriate context type**: Global computations for system-wide values, property computations for entity-specific values
|
|
1726
|
+
3. **Use correct dataDeps type**:
|
|
1727
|
+
- `type: 'property'` for accessing current record data in Property computations (use nested attributeQuery for relations)
|
|
1728
|
+
- `type: 'records'` for global queries in Dictionary computations
|
|
1729
|
+
- Never use `type: 'records'` in Property computations (won't access the specific record's relations)
|
|
1730
|
+
4. **Handle missing data gracefully**: Check for null/undefined in dataDeps
|
|
1731
|
+
5. **Optimize with incremental computation**: Use `incrementalCompute` for expensive calculations
|
|
1732
|
+
6. **Property computations need triggers**: Property-level computations require global data dependencies to trigger on entity creation
|
|
1733
|
+
|
|
1734
|
+
### Dictionary.create()
|
|
1735
|
+
|
|
1736
|
+
Create global dictionary for storing system-wide state and values.
|
|
1737
|
+
|
|
1738
|
+
**Syntax**
|
|
1739
|
+
```typescript
|
|
1740
|
+
Dictionary.create(config: DictionaryConfig): DictionaryInstance
|
|
1741
|
+
```
|
|
1742
|
+
|
|
1743
|
+
**Parameters**
|
|
1744
|
+
- `config.name` (string, required): Dictionary name
|
|
1745
|
+
- `config.type` (string, required): Value type, must be one of PropertyTypes (e.g., 'string', 'number', 'boolean', 'object', etc.)
|
|
1746
|
+
- `config.collection` (boolean, required): Whether it's a collection type, defaults to false
|
|
1747
|
+
- `config.args` (object, optional): Type-specific arguments (e.g., string length, number range)
|
|
1748
|
+
- `config.defaultValue` (function, optional): Default value generator function
|
|
1749
|
+
- `config.computation` (Computation, optional): Reactive computation for the dictionary value
|
|
1750
|
+
|
|
1751
|
+
**Examples**
|
|
1752
|
+
```typescript
|
|
1753
|
+
// Simple global counter
|
|
1754
|
+
const userCountDict = Dictionary.create({
|
|
1755
|
+
name: 'userCount',
|
|
1756
|
+
type: 'number',
|
|
1757
|
+
collection: false,
|
|
1758
|
+
computation: Count.create({
|
|
1759
|
+
record: User
|
|
1760
|
+
})
|
|
1761
|
+
})
|
|
1762
|
+
|
|
1763
|
+
// System configuration
|
|
1764
|
+
const systemConfig = Dictionary.create({
|
|
1765
|
+
name: 'config',
|
|
1766
|
+
type: 'object',
|
|
1767
|
+
collection: false,
|
|
1768
|
+
defaultValue: () => ({
|
|
1769
|
+
maxUsers: 1000,
|
|
1770
|
+
maintenanceMode: false
|
|
1771
|
+
})
|
|
1772
|
+
})
|
|
1773
|
+
|
|
1774
|
+
// Real-time values
|
|
1775
|
+
const currentTime = Dictionary.create({
|
|
1776
|
+
name: 'currentTime',
|
|
1777
|
+
type: 'number',
|
|
1778
|
+
collection: false,
|
|
1779
|
+
computation: RealTime.create({
|
|
1780
|
+
nextRecomputeTime: () => 1000, // Update every second
|
|
1781
|
+
callback: async (now) => {
|
|
1782
|
+
return now.divide(1000);
|
|
1783
|
+
}
|
|
1784
|
+
})
|
|
1785
|
+
})
|
|
1786
|
+
|
|
1787
|
+
// Collection type dictionary
|
|
1788
|
+
const activeUsers = Dictionary.create({
|
|
1789
|
+
name: 'activeUsers',
|
|
1790
|
+
type: 'string',
|
|
1791
|
+
collection: true,
|
|
1792
|
+
computation: Transform.create({
|
|
1793
|
+
record: User,
|
|
1794
|
+
attributeQuery: ['id', 'lastLoginTime'],
|
|
1795
|
+
callback: async function(this: Controller, users) {
|
|
1796
|
+
// Use Controller to get activity threshold from config
|
|
1797
|
+
const activityThreshold = await this.system.storage.get('config', 'userActivityThreshold', 3600000); // Default 1 hour
|
|
1798
|
+
const cutoffTime = Date.now() - activityThreshold;
|
|
1799
|
+
|
|
1800
|
+
return users
|
|
1801
|
+
.filter(u => u.lastLoginTime > cutoffTime)
|
|
1802
|
+
.map(u => u.id);
|
|
1803
|
+
}
|
|
1804
|
+
})
|
|
1805
|
+
})
|
|
1806
|
+
```
|
|
1807
|
+
|
|
1808
|
+
**Usage in Controller**
|
|
1809
|
+
|
|
1810
|
+
Dictionaries are passed as the 6th parameter to Controller:
|
|
1811
|
+
|
|
1812
|
+
```typescript
|
|
1813
|
+
const controller = new Controller({
|
|
1814
|
+
system: system,
|
|
1815
|
+
entities: entities,
|
|
1816
|
+
relations: relations,
|
|
1817
|
+
activities: activities,
|
|
1818
|
+
interactions: interactions,
|
|
1819
|
+
dict: [userCountDict, systemConfig, currentTime, activeUsers],, // Dictionaries
|
|
1820
|
+
recordMutationSideEffects: []
|
|
1821
|
+
});
|
|
1822
|
+
```
|
|
1823
|
+
|
|
1824
|
+
### StateNode.create()
|
|
1825
|
+
|
|
1826
|
+
Create state node for state machine computation.
|
|
1827
|
+
|
|
1828
|
+
**Syntax**
|
|
1829
|
+
```typescript
|
|
1830
|
+
StateNode.create(config: StateNodeConfig): StateNodeInstance
|
|
1831
|
+
```
|
|
1832
|
+
|
|
1833
|
+
**Parameters**
|
|
1834
|
+
- `config.name` (string, required): State name identifier
|
|
1835
|
+
- `config.computeValue` (function, optional): Function to compute the value when transitioning to this state
|
|
1836
|
+
|
|
1837
|
+
**computeValue Function**
|
|
1838
|
+
|
|
1839
|
+
The `computeValue` function determines what value should be stored when the state machine transitions to this state:
|
|
1840
|
+
|
|
1841
|
+
- **Function Signature**:
|
|
1842
|
+
- Sync: `(lastValue?: any, event?: InteractionEvent) => any`
|
|
1843
|
+
- Async: `async (lastValue?: any, event?: InteractionEvent) => Promise<any>`
|
|
1844
|
+
- `lastValue`: The previous value before the state transition (may be undefined for initial state)
|
|
1845
|
+
- `event`: The interaction event that triggered the state transition (optional)
|
|
1846
|
+
- Contains `user`, `payload`, `interactionName`, and other interaction metadata
|
|
1847
|
+
- Only available when state transition is triggered by an interaction
|
|
1848
|
+
- May be undefined for default state initialization
|
|
1849
|
+
- Returns: The new value to be stored (can be a Promise)
|
|
1850
|
+
- **Context**: Called with `this` bound to the Controller instance
|
|
1851
|
+
- **Async Support**: Yes, `computeValue` can be an async function
|
|
1852
|
+
- **Purpose**:
|
|
1853
|
+
- Store state-specific data (e.g., timestamps, user IDs)
|
|
1854
|
+
- Transform or calculate values based on the previous state
|
|
1855
|
+
- Access interaction context (user, payload) during state transitions
|
|
1856
|
+
- Return complex objects with multiple properties
|
|
1857
|
+
- Return `null` to indicate deletion (for entity/relation state machines)
|
|
1858
|
+
- Perform async operations like database queries or API calls
|
|
1859
|
+
|
|
1860
|
+
**Examples**
|
|
1861
|
+
```typescript
|
|
1862
|
+
// Simple state node (no value computation)
|
|
1863
|
+
const pendingState = StateNode.create({ name: 'pending' });
|
|
1864
|
+
|
|
1865
|
+
// Store timestamp when entering state
|
|
1866
|
+
const processedState = StateNode.create({
|
|
1867
|
+
name: 'processed',
|
|
1868
|
+
computeValue: (lastValue, mutationEvent) => new Date().toISOString()
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
// Store complex object
|
|
1872
|
+
const activeState = StateNode.create({
|
|
1873
|
+
name: 'active',
|
|
1874
|
+
computeValue: (lastValue, mutationEvent) => ({
|
|
1875
|
+
activatedAt: Math.floor(Date.now()/1000), // In seconds
|
|
1876
|
+
status: 'active',
|
|
1877
|
+
metadata: { source: 'manual' }
|
|
1878
|
+
})
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
// Increment value based on previous state
|
|
1882
|
+
const incrementingState = StateNode.create({
|
|
1883
|
+
name: 'incrementing',
|
|
1884
|
+
computeValue: (lastValue, mutationEvent) => {
|
|
1885
|
+
const baseValue = typeof lastValue === 'number' ? lastValue : 0;
|
|
1886
|
+
return baseValue + 1;
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
// Preserve previous value
|
|
1891
|
+
const idleState = StateNode.create({
|
|
1892
|
+
name: 'idle',
|
|
1893
|
+
computeValue: (lastValue, mutationEvent) => {
|
|
1894
|
+
return typeof lastValue === 'number' ? lastValue : 0;
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
// Return null to delete (for entity/relation state machines)
|
|
1899
|
+
const deletedState = StateNode.create({
|
|
1900
|
+
name: 'deleted',
|
|
1901
|
+
computeValue: (lastValue, mutationEvent) => null
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
// Async computeValue - fetch data from database
|
|
1905
|
+
const enrichedState = StateNode.create({
|
|
1906
|
+
name: 'enriched',
|
|
1907
|
+
computeValue: async function(lastValue) {
|
|
1908
|
+
// Access controller to query database
|
|
1909
|
+
const relatedData = await this.system.storage.findOne('RelatedEntity',
|
|
1910
|
+
this.globals.MatchExp.atom({ key: 'id', value: ['=', lastValue.relatedId] }),
|
|
1911
|
+
undefined,
|
|
1912
|
+
['name', 'metadata']
|
|
1913
|
+
);
|
|
1914
|
+
|
|
1915
|
+
return {
|
|
1916
|
+
...lastValue,
|
|
1917
|
+
enrichedAt: new Date().toISOString(),
|
|
1918
|
+
relatedInfo: relatedData
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
// Async computeValue - external API call
|
|
1924
|
+
const verifiedState = StateNode.create({
|
|
1925
|
+
name: 'verified',
|
|
1926
|
+
computeValue: async (lastValue, mutationEvent) => {
|
|
1927
|
+
// Simulate external verification
|
|
1928
|
+
const verificationResult = await someExternalAPI.verify(lastValue.data);
|
|
1929
|
+
|
|
1930
|
+
return {
|
|
1931
|
+
status: 'verified',
|
|
1932
|
+
verifiedAt: new Date().toISOString(),
|
|
1933
|
+
verificationId: verificationResult.id,
|
|
1934
|
+
confidence: verificationResult.confidence
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
// Using event parameter - access user information
|
|
1940
|
+
const approvedState = StateNode.create({
|
|
1941
|
+
name: 'approved',
|
|
1942
|
+
computeValue: (lastValue, mutationEvent) => {
|
|
1943
|
+
// Access user who triggered the approval
|
|
1944
|
+
const approver = mutationEvent?.record?.user?.name || 'system';
|
|
1945
|
+
return {
|
|
1946
|
+
status: 'approved',
|
|
1947
|
+
approvedAt: Math.floor(Date.now()/1000), // In seconds
|
|
1948
|
+
approvedBy: approver
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
// Using event parameter - access payload data
|
|
1954
|
+
const updatedState = StateNode.create({
|
|
1955
|
+
name: 'updated',
|
|
1956
|
+
computeValue: (lastValue, mutationEvent) => {
|
|
1957
|
+
// Merge payload data with existing value
|
|
1958
|
+
if (mutationEvent?.record?.payload) {
|
|
1959
|
+
return {
|
|
1960
|
+
...lastValue,
|
|
1961
|
+
...mutationEvent.record.payload.updates,
|
|
1962
|
+
lastModifiedAt: Math.floor(Date.now()/1000), // In seconds
|
|
1963
|
+
lastModifiedBy: mutationEvent.record.user?.id
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
return lastValue;
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
// Using event parameter - conditional logic based on interaction
|
|
1971
|
+
const reviewedState = StateNode.create({
|
|
1972
|
+
name: 'reviewed',
|
|
1973
|
+
computeValue: (lastValue, mutationEvent) => {
|
|
1974
|
+
// Different behavior based on which interaction triggered the transition
|
|
1975
|
+
if (mutationEvent?.record?.interactionName === 'approve') {
|
|
1976
|
+
return { status: 'approved', reviewedAt: Math.floor(Date.now()/1000) }; // In seconds
|
|
1977
|
+
} else if (mutationEvent?.record?.interactionName === 'reject') {
|
|
1978
|
+
return {
|
|
1979
|
+
status: 'rejected',
|
|
1980
|
+
reviewedAt: Math.floor(Date.now()/1000), // In seconds
|
|
1981
|
+
reason: mutationEvent.record.payload?.reason || 'No reason provided'
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
return { status: 'pending_review' };
|
|
1985
|
+
}
|
|
1986
|
+
});
|
|
1987
|
+
```
|
|
1988
|
+
|
|
1989
|
+
**Usage in StateMachine**
|
|
1990
|
+
|
|
1991
|
+
StateNodes with `computeValue` are used in StateMachine to compute property values or entity states:
|
|
1992
|
+
|
|
1993
|
+
```typescript
|
|
1994
|
+
// Counter that increments on interaction
|
|
1995
|
+
const idleState = StateNode.create({
|
|
1996
|
+
name: 'idle',
|
|
1997
|
+
computeValue: (lastValue, mutationEvent) => lastValue || 0
|
|
1998
|
+
});
|
|
1999
|
+
const incrementingState = StateNode.create({
|
|
2000
|
+
name: 'incrementing',
|
|
2001
|
+
computeValue: (lastValue, mutationEvent) => (lastValue || 0) + 1
|
|
2002
|
+
});
|
|
2003
|
+
|
|
2004
|
+
const CounterStateMachine = StateMachine.create({
|
|
2005
|
+
states: [idleState, incrementingState],
|
|
2006
|
+
transfers: [
|
|
2007
|
+
StateTransfer.create({
|
|
2008
|
+
trigger: {
|
|
2009
|
+
recordName: InteractionEventEntity.name,
|
|
2010
|
+
record: {
|
|
2011
|
+
interactionName: IncrementInteraction.name
|
|
2012
|
+
}
|
|
2013
|
+
},
|
|
2014
|
+
current: idleState,
|
|
2015
|
+
next: incrementingState,
|
|
2016
|
+
computeTarget: (mutationEvent) => ({ id: mutationEvent.record.payload.counterId })
|
|
2017
|
+
})
|
|
2018
|
+
],
|
|
2019
|
+
defaultState: idleState
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
// Timestamp tracking
|
|
2023
|
+
const pendingState = StateNode.create({ name: 'pending' });
|
|
2024
|
+
const processedState = StateNode.create({
|
|
2025
|
+
name: 'processed',
|
|
2026
|
+
computeValue: (lastValue, mutationEvent) => new Date().toISOString()
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
const ProcessingStateMachine = StateMachine.create({
|
|
2030
|
+
states: [pendingState, processedState],
|
|
2031
|
+
transfers: [
|
|
2032
|
+
StateTransfer.create({
|
|
2033
|
+
trigger: {
|
|
2034
|
+
recordName: InteractionEventEntity.name,
|
|
2035
|
+
record: {
|
|
2036
|
+
interactionName: ProcessInteraction.name
|
|
2037
|
+
}
|
|
2038
|
+
},
|
|
2039
|
+
current: pendingState,
|
|
2040
|
+
next: processedState,
|
|
2041
|
+
computeTarget: (mutationEvent) => ({ id: mutationEvent.record.payload.itemId })
|
|
2042
|
+
})
|
|
2043
|
+
],
|
|
2044
|
+
defaultState: pendingState
|
|
2045
|
+
});
|
|
2046
|
+
|
|
2047
|
+
// Apply to property
|
|
2048
|
+
const SomeEntity = Entity.create({
|
|
2049
|
+
name: 'SomeEntity',
|
|
2050
|
+
properties: [
|
|
2051
|
+
Property.create({
|
|
2052
|
+
name: 'processedAt',
|
|
2053
|
+
type: 'string',
|
|
2054
|
+
computation: ProcessingStateMachine
|
|
2055
|
+
})
|
|
2056
|
+
]
|
|
2057
|
+
});
|
|
2058
|
+
```
|
|
2059
|
+
|
|
2060
|
+
### StateTransfer.create()
|
|
2061
|
+
|
|
2062
|
+
Create state transfer for state machine computation.
|
|
2063
|
+
|
|
2064
|
+
**Syntax**
|
|
2065
|
+
```typescript
|
|
2066
|
+
StateTransfer.create(config: StateTransferConfig): StateTransferInstance
|
|
2067
|
+
```
|
|
2068
|
+
|
|
2069
|
+
**Parameters**
|
|
2070
|
+
- `config.trigger` (RecordMutationEventPattern, required): A partial pattern to match against RecordMutationEvent. Supports deep partial matching of event properties.
|
|
2071
|
+
- `config.current` (StateNode, required): Current state node
|
|
2072
|
+
- `config.next` (StateNode, required): Next state node
|
|
2073
|
+
- `config.computeTarget` (function, optional): Function to compute which records should undergo this state transition. Returns the target record(s) that should be affected by this state change.
|
|
2074
|
+
|
|
2075
|
+
**computeTarget Function**
|
|
2076
|
+
|
|
2077
|
+
The `computeTarget` function determines which specific records should transition states when the trigger occurs:
|
|
2078
|
+
|
|
2079
|
+
- **For Entity StateMachines**: Return entity record(s) with `id` field: `{id: string}` or array of such objects
|
|
2080
|
+
- **For Relation StateMachines**:
|
|
2081
|
+
- Return new relation specification: `{source: EntityRef, target: EntityRef}` where EntityRef can be:
|
|
2082
|
+
- An object with `id`: `{id: string}`
|
|
2083
|
+
- A full entity record: `{id: string, ...otherFields}`
|
|
2084
|
+
- The source/target can also be arrays for batch operations
|
|
2085
|
+
- Or return existing relation record(s) with `id`
|
|
2086
|
+
- **Function Signature**:
|
|
2087
|
+
- Sync: `(event: InteractionEvent) => TargetRecord`
|
|
2088
|
+
- Async: `async function(this: Controller, event: InteractionEvent) => TargetRecord`
|
|
2089
|
+
- **Event Parameter**: Contains interaction details including `event.payload` with the interaction's payload data
|
|
2090
|
+
|
|
2091
|
+
**Trigger Pattern**
|
|
2092
|
+
|
|
2093
|
+
The `trigger` parameter now accepts a RecordMutationEventPattern that allows deep partial matching:
|
|
2094
|
+
|
|
2095
|
+
```typescript
|
|
2096
|
+
// Match interaction events by name
|
|
2097
|
+
trigger: {
|
|
2098
|
+
recordName: InteractionEventEntity.name, // Use the constant, not hardcoded string
|
|
2099
|
+
record: {
|
|
2100
|
+
interactionName: 'approve' // Or better: ApproveInteraction.name
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// Match data mutation events
|
|
2105
|
+
trigger: {
|
|
2106
|
+
recordName: 'User',
|
|
2107
|
+
type: 'update' // Match only update events
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// Match with complex patterns
|
|
2111
|
+
trigger: {
|
|
2112
|
+
recordName: InteractionEventEntity.name,
|
|
2113
|
+
record: {
|
|
2114
|
+
interactionName: 'updateStatus',
|
|
2115
|
+
payload: {
|
|
2116
|
+
status: 'active' // Only trigger when status is set to 'active'
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
```
|
|
2121
|
+
|
|
2122
|
+
**Examples**
|
|
2123
|
+
```typescript
|
|
2124
|
+
// Simple state transfer (no computeTarget needed for global state)
|
|
2125
|
+
const approveTransfer = StateTransfer.create({
|
|
2126
|
+
trigger: {
|
|
2127
|
+
recordName: InteractionEventEntity.name,
|
|
2128
|
+
record: {
|
|
2129
|
+
interactionName: ApproveInteraction.name
|
|
2130
|
+
}
|
|
2131
|
+
},
|
|
2132
|
+
current: pendingState,
|
|
2133
|
+
next: approvedState
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
// Entity state transfer - specify which entity to update
|
|
2137
|
+
const incrementTransfer = StateTransfer.create({
|
|
2138
|
+
trigger: {
|
|
2139
|
+
recordName: InteractionEventEntity.name,
|
|
2140
|
+
record: {
|
|
2141
|
+
interactionName: IncrementInteraction.name
|
|
2142
|
+
}
|
|
2143
|
+
},
|
|
2144
|
+
current: idleState,
|
|
2145
|
+
next: incrementingState,
|
|
2146
|
+
computeTarget: (mutationEvent) => {
|
|
2147
|
+
// Return the counter entity that should transition states
|
|
2148
|
+
return { id: mutationEvent.record.payload.counter.id };
|
|
2149
|
+
}
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
// Relation state transfer - create new relation
|
|
2153
|
+
const assignReviewerTransfer = StateTransfer.create({
|
|
2154
|
+
trigger: {
|
|
2155
|
+
recordName: InteractionEventEntity.name,
|
|
2156
|
+
record: {
|
|
2157
|
+
interactionName: AssignReviewerInteraction.name
|
|
2158
|
+
}
|
|
2159
|
+
},
|
|
2160
|
+
current: unassignedState,
|
|
2161
|
+
next: assignedState,
|
|
2162
|
+
computeTarget: async function(this: Controller, mutationEvent) {
|
|
2163
|
+
// Find the request entity
|
|
2164
|
+
const request = await this.system.storage.findOne('Request',
|
|
2165
|
+
this.globals.MatchExp.atom({
|
|
2166
|
+
key: 'id',
|
|
2167
|
+
value: ['=', mutationEvent.record.payload.requestId]
|
|
2168
|
+
}),
|
|
2169
|
+
undefined,
|
|
2170
|
+
['id']
|
|
2171
|
+
);
|
|
2172
|
+
|
|
2173
|
+
// Return source and target to create/update relation
|
|
2174
|
+
return {
|
|
2175
|
+
source: request, // Can be {id: '...'} or full entity
|
|
2176
|
+
target: mutationEvent.record.payload.reviewer // From payload
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2181
|
+
// Trigger on specific payload values
|
|
2182
|
+
const publishTransfer = StateTransfer.create({
|
|
2183
|
+
trigger: {
|
|
2184
|
+
recordName: InteractionEventEntity.name,
|
|
2185
|
+
record: {
|
|
2186
|
+
interactionName: UpdateStatusInteraction.name,
|
|
2187
|
+
payload: {
|
|
2188
|
+
newStatus: 'published' // Only trigger when status changes to 'published'
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
},
|
|
2192
|
+
current: draftState,
|
|
2193
|
+
next: publishedState,
|
|
2194
|
+
computeTarget: (mutationEvent) => ({ id: mutationEvent.record.payload.documentId })
|
|
2195
|
+
});
|
|
2196
|
+
|
|
2197
|
+
// Trigger on data mutations (non-interaction events)
|
|
2198
|
+
const dataUpdateTransfer = StateTransfer.create({
|
|
2199
|
+
trigger: {
|
|
2200
|
+
recordName: 'Order',
|
|
2201
|
+
type: 'update' // Only trigger on Order updates
|
|
2202
|
+
},
|
|
2203
|
+
current: processingState,
|
|
2204
|
+
next: updatedState,
|
|
2205
|
+
computeTarget: (mutationEvent) => ({ id: mutationEvent.record.id })
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
// Multiple targets - return array
|
|
2209
|
+
const bulkApproveTransfer = StateTransfer.create({
|
|
2210
|
+
trigger: {
|
|
2211
|
+
recordName: InteractionEventEntity.name,
|
|
2212
|
+
record: {
|
|
2213
|
+
interactionName: BulkApproveInteraction.name
|
|
2214
|
+
}
|
|
2215
|
+
},
|
|
2216
|
+
current: pendingState,
|
|
2217
|
+
next: approvedState,
|
|
2218
|
+
computeTarget: (mutationEvent) => {
|
|
2219
|
+
// Return multiple entities to transition
|
|
2220
|
+
return mutationEvent.record.payload.items.map(item => ({ id: item.id }));
|
|
2221
|
+
}
|
|
2222
|
+
});
|
|
2223
|
+
```
|
|
2224
|
+
|
|
2225
|
+
## 13.3 Interaction-Related APIs
|
|
2226
|
+
|
|
2227
|
+
### Interaction.create()
|
|
2228
|
+
|
|
2229
|
+
Create user interaction definition.
|
|
2230
|
+
|
|
2231
|
+
**Syntax**
|
|
2232
|
+
```typescript
|
|
2233
|
+
Interaction.create(config: InteractionConfig): InteractionInstance
|
|
2234
|
+
```
|
|
2235
|
+
|
|
2236
|
+
**Parameters**
|
|
2237
|
+
- `config.name` (string, required): Interaction name
|
|
2238
|
+
- `config.action` (Action, required): Interaction action
|
|
2239
|
+
- `config.payload` (Payload, optional): Interaction parameters
|
|
2240
|
+
- `config.conditions` (Condition|Conditions, optional): Execution conditions
|
|
2241
|
+
- `config.sideEffects` (SideEffect[], optional): Side effect handlers
|
|
2242
|
+
- `config.data` (Entity|Relation, optional): Associated data entity
|
|
2243
|
+
- `config.query` (Query, optional): Query definition for data fetching
|
|
2244
|
+
|
|
2245
|
+
**Examples**
|
|
2246
|
+
|
|
2247
|
+
1. **Create Data Interaction**
|
|
2248
|
+
```typescript
|
|
2249
|
+
// Create post interaction
|
|
2250
|
+
const CreatePostInteraction = Interaction.create({
|
|
2251
|
+
name: 'createPost',
|
|
2252
|
+
action: Action.create({ name: 'create' }),
|
|
2253
|
+
payload: Payload.create({
|
|
2254
|
+
items: [
|
|
2255
|
+
PayloadItem.create({
|
|
2256
|
+
name: 'postData',
|
|
2257
|
+
base: Post,
|
|
2258
|
+
required: true
|
|
2259
|
+
})
|
|
2260
|
+
]
|
|
2261
|
+
}),
|
|
2262
|
+
conditions: Condition.create({
|
|
2263
|
+
name: 'AuthenticatedUser',
|
|
2264
|
+
content: async function(event) {
|
|
2265
|
+
return event.user.id !== undefined
|
|
2266
|
+
}
|
|
2267
|
+
})
|
|
2268
|
+
})
|
|
2269
|
+
```
|
|
2270
|
+
|
|
2271
|
+
2. **Get Data Interactions**
|
|
2272
|
+
|
|
2273
|
+
To retrieve data, use `GetAction` and specify the `data` field:
|
|
2274
|
+
|
|
2275
|
+
```typescript
|
|
2276
|
+
// Get all users
|
|
2277
|
+
const GetAllUsers = Interaction.create({
|
|
2278
|
+
name: 'getAllUsers',
|
|
2279
|
+
action: GetAction, // Special action for data retrieval
|
|
2280
|
+
data: User // Entity to query
|
|
2281
|
+
})
|
|
2282
|
+
|
|
2283
|
+
// Get filtered data with query configuration
|
|
2284
|
+
const GetActiveUsers = Interaction.create({
|
|
2285
|
+
name: 'getActiveUsers',
|
|
2286
|
+
action: GetAction,
|
|
2287
|
+
data: User,
|
|
2288
|
+
query: Query.create({
|
|
2289
|
+
items: [
|
|
2290
|
+
QueryItem.create({
|
|
2291
|
+
name: 'attributeQuery',
|
|
2292
|
+
value: ['id', 'name', 'email', 'status'] // Fields to retrieve
|
|
2293
|
+
})
|
|
2294
|
+
]
|
|
2295
|
+
})
|
|
2296
|
+
})
|
|
2297
|
+
|
|
2298
|
+
// Get data with complex conditions
|
|
2299
|
+
const GetUserPosts = Interaction.create({
|
|
2300
|
+
name: 'getUserPosts',
|
|
2301
|
+
action: GetAction,
|
|
2302
|
+
data: Post,
|
|
2303
|
+
conditions: Condition.create({
|
|
2304
|
+
name: 'canViewPosts',
|
|
2305
|
+
content: async function(this: Controller, event) {
|
|
2306
|
+
// Check if user can view posts based on query
|
|
2307
|
+
const targetUserId = event.query?.match?.key === 'author.id'
|
|
2308
|
+
? event.query.match.value[1]
|
|
2309
|
+
: null;
|
|
2310
|
+
|
|
2311
|
+
// Allow users to see their own posts or public posts
|
|
2312
|
+
return targetUserId === event.user.id || event.user.role === 'admin';
|
|
2313
|
+
}
|
|
2314
|
+
})
|
|
2315
|
+
})
|
|
2316
|
+
|
|
2317
|
+
// Get paginated data
|
|
2318
|
+
const GetPostsPaginated = Interaction.create({
|
|
2319
|
+
name: 'getPostsPaginated',
|
|
2320
|
+
action: GetAction,
|
|
2321
|
+
data: Post,
|
|
2322
|
+
query: Query.create({
|
|
2323
|
+
items: [
|
|
2324
|
+
QueryItem.create({
|
|
2325
|
+
name: 'attributeQuery',
|
|
2326
|
+
value: ['id', 'title', 'content', 'createdAt', 'author']
|
|
2327
|
+
}),
|
|
2328
|
+
QueryItem.create({
|
|
2329
|
+
name: 'modifier',
|
|
2330
|
+
value: { limit: 10, offset: 0 } // Pagination
|
|
2331
|
+
})
|
|
2332
|
+
]
|
|
2333
|
+
})
|
|
2334
|
+
})
|
|
2335
|
+
```
|
|
2336
|
+
|
|
2337
|
+
**Usage Examples for Get Data Interactions**
|
|
2338
|
+
|
|
2339
|
+
```typescript
|
|
2340
|
+
// Get all users
|
|
2341
|
+
const result = await controller.callInteraction('getAllUsers', {
|
|
2342
|
+
user: { id: 'admin-user' }
|
|
2343
|
+
})
|
|
2344
|
+
// result.data contains array of users
|
|
2345
|
+
|
|
2346
|
+
// Get filtered users with runtime query
|
|
2347
|
+
const activeUsersResult = await controller.callInteraction('getActiveUsers', {
|
|
2348
|
+
user: { id: 'admin-user' },
|
|
2349
|
+
query: {
|
|
2350
|
+
match: MatchExp.atom({ key: 'status', value: ['=', 'active'] }),
|
|
2351
|
+
attributeQuery: ['id', 'name', 'email']
|
|
2352
|
+
}
|
|
2353
|
+
})
|
|
2354
|
+
// result.data contains filtered users with specified fields
|
|
2355
|
+
|
|
2356
|
+
// Get user's posts
|
|
2357
|
+
const userPostsResult = await controller.callInteraction('getUserPosts', {
|
|
2358
|
+
user: { id: 'user123' },
|
|
2359
|
+
query: {
|
|
2360
|
+
match: MatchExp.atom({ key: 'author.id', value: ['=', 'user123'] }),
|
|
2361
|
+
attributeQuery: ['id', 'title', 'content', 'createdAt']
|
|
2362
|
+
}
|
|
2363
|
+
})
|
|
2364
|
+
// result.data contains posts authored by user123
|
|
2365
|
+
|
|
2366
|
+
// Get paginated posts
|
|
2367
|
+
const paginatedResult = await controller.callInteraction('getPostsPaginated', {
|
|
2368
|
+
user: { id: 'user123' },
|
|
2369
|
+
query: {
|
|
2370
|
+
match: MatchExp.atom({ key: 'status', value: ['=', 'published'] }),
|
|
2371
|
+
modifier: { limit: 10, offset: 20 }, // Get page 3 (assuming 10 per page)
|
|
2372
|
+
attributeQuery: ['id', 'title', 'createdAt']
|
|
2373
|
+
}
|
|
2374
|
+
})
|
|
2375
|
+
// result.data contains 10 posts starting from offset 20
|
|
2376
|
+
```
|
|
2377
|
+
|
|
2378
|
+
**Key Points for Data Retrieval**
|
|
2379
|
+
|
|
2380
|
+
1. **Required**: Must use `GetAction` as the action
|
|
2381
|
+
2. **Required**: Must specify `data` field with the Entity or Relation to query
|
|
2382
|
+
3. **Optional**: Use `query` in Interaction definition for fixed query configuration
|
|
2383
|
+
4. **Runtime Query**: Pass `query` object in `callInteraction` to dynamically filter data
|
|
2384
|
+
5. **Conditions**: Can use conditions to control access based on query parameters
|
|
2385
|
+
6. **Response**: Retrieved data is returned in `result.data` field
|
|
2386
|
+
|
|
2387
|
+
### Action.create()
|
|
2388
|
+
|
|
2389
|
+
Create interaction action identifier.
|
|
2390
|
+
|
|
2391
|
+
⚠️ **Important: Action is not an "operation" but an identifier**
|
|
2392
|
+
|
|
2393
|
+
Action is just a name for interaction types, like event type labels. It contains no operation logic or execution code.
|
|
2394
|
+
|
|
2395
|
+
**Syntax**
|
|
2396
|
+
```typescript
|
|
2397
|
+
Action.create(config: ActionConfig): ActionInstance
|
|
2398
|
+
```
|
|
2399
|
+
|
|
2400
|
+
**Parameters**
|
|
2401
|
+
- `config.name` (string, required): Action type identifier name
|
|
2402
|
+
|
|
2403
|
+
**Examples**
|
|
2404
|
+
```typescript
|
|
2405
|
+
// These are just identifiers, containing no operation logic
|
|
2406
|
+
const CreateAction = Action.create({ name: 'create' })
|
|
2407
|
+
const UpdateAction = Action.create({ name: 'update' })
|
|
2408
|
+
const DeleteAction = Action.create({ name: 'delete' })
|
|
2409
|
+
const LikeAction = Action.create({ name: 'like' })
|
|
2410
|
+
|
|
2411
|
+
// ❌ Wrong understanding: thinking Action contains operation logic
|
|
2412
|
+
const WrongAction = Action.create({
|
|
2413
|
+
name: 'create',
|
|
2414
|
+
execute: () => { /* ... */ } // ❌ Action has no execute method!
|
|
2415
|
+
})
|
|
2416
|
+
|
|
2417
|
+
// ✅ Correct understanding: Action is just an identifier
|
|
2418
|
+
const CorrectAction = Action.create({
|
|
2419
|
+
name: 'create' // That's it!
|
|
2420
|
+
})
|
|
2421
|
+
```
|
|
2422
|
+
|
|
2423
|
+
**Where is the operation logic?**
|
|
2424
|
+
|
|
2425
|
+
All operation logic is implemented through reactive computations (Transform, Count, etc.):
|
|
2426
|
+
|
|
2427
|
+
```typescript
|
|
2428
|
+
// Interaction just declares that users can create posts
|
|
2429
|
+
const CreatePost = Interaction.create({
|
|
2430
|
+
name: 'CreatePost',
|
|
2431
|
+
action: Action.create({ name: 'create' }), // Just an identifier
|
|
2432
|
+
payload: Payload.create({ /* ... */ })
|
|
2433
|
+
});
|
|
2434
|
+
|
|
2435
|
+
// The actual "create" logic is in Transform
|
|
2436
|
+
const UserPostRelation = Relation.create({
|
|
2437
|
+
source: User,
|
|
2438
|
+
target: Post,
|
|
2439
|
+
computation: Transform.create({
|
|
2440
|
+
eventDeps: {
|
|
2441
|
+
PostInteraction: {
|
|
2442
|
+
recordName: InteractionEventEntity.name,
|
|
2443
|
+
type: 'create'
|
|
2444
|
+
}
|
|
2445
|
+
},
|
|
2446
|
+
callback: async function(this: Controller, mutationEvent) {
|
|
2447
|
+
const event = mutationEvent.record;
|
|
2448
|
+
if (event.interactionName === 'CreatePost') {
|
|
2449
|
+
// Use Controller to validate and enhance data
|
|
2450
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2451
|
+
|
|
2452
|
+
// This is where the actual creation logic is
|
|
2453
|
+
return {
|
|
2454
|
+
source: event.user.id,
|
|
2455
|
+
target: {
|
|
2456
|
+
title: event.payload.title,
|
|
2457
|
+
content: event.payload.content,
|
|
2458
|
+
createdAt: now,
|
|
2459
|
+
updatedAt: now
|
|
2460
|
+
}
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
})
|
|
2465
|
+
});
|
|
2466
|
+
```
|
|
2467
|
+
|
|
2468
|
+
### Payload.create()
|
|
2469
|
+
|
|
2470
|
+
Create interaction parameter definition.
|
|
2471
|
+
|
|
2472
|
+
**Syntax**
|
|
2473
|
+
```typescript
|
|
2474
|
+
Payload.create(config: PayloadConfig): PayloadInstance
|
|
2475
|
+
```
|
|
2476
|
+
|
|
2477
|
+
**Parameters**
|
|
2478
|
+
- `config.items` (PayloadItem[], required): Parameter item list, defaults to empty array
|
|
2479
|
+
|
|
2480
|
+
**Examples**
|
|
2481
|
+
```typescript
|
|
2482
|
+
const CreateUserPayload = Payload.create({
|
|
2483
|
+
items: [
|
|
2484
|
+
PayloadItem.create({
|
|
2485
|
+
name: 'userData',
|
|
2486
|
+
required: true
|
|
2487
|
+
}),
|
|
2488
|
+
PayloadItem.create({
|
|
2489
|
+
name: 'profileData',
|
|
2490
|
+
required: false
|
|
2491
|
+
})
|
|
2492
|
+
]
|
|
2493
|
+
})
|
|
2494
|
+
```
|
|
2495
|
+
|
|
2496
|
+
### PayloadItem.create()
|
|
2497
|
+
|
|
2498
|
+
Create interaction parameter item.
|
|
2499
|
+
|
|
2500
|
+
**Syntax**
|
|
2501
|
+
```typescript
|
|
2502
|
+
PayloadItem.create(config: PayloadItemConfig): PayloadItemInstance
|
|
2503
|
+
```
|
|
2504
|
+
|
|
2505
|
+
**Parameters**
|
|
2506
|
+
- `config.name` (string, required): Parameter name
|
|
2507
|
+
- `config.required` (boolean, optional): Whether it's required, defaults to false
|
|
2508
|
+
- `config.isCollection` (boolean, optional): Whether it's a collection type, defaults to false
|
|
2509
|
+
- `config.itemRef` (Attributive|Entity, optional): Used to reference entities defined in other interactions within Activity
|
|
2510
|
+
|
|
2511
|
+
**Examples**
|
|
2512
|
+
```typescript
|
|
2513
|
+
// Reference existing user
|
|
2514
|
+
const userRef = PayloadItem.create({
|
|
2515
|
+
name: 'user',
|
|
2516
|
+
required: true
|
|
2517
|
+
})
|
|
2518
|
+
|
|
2519
|
+
// Create new post data
|
|
2520
|
+
const postData = PayloadItem.create({
|
|
2521
|
+
name: 'postData',
|
|
2522
|
+
required: true
|
|
2523
|
+
})
|
|
2524
|
+
|
|
2525
|
+
// Collection type reference
|
|
2526
|
+
const reviewersItem = PayloadItem.create({
|
|
2527
|
+
name: 'reviewers',
|
|
2528
|
+
isCollection: true
|
|
2529
|
+
})
|
|
2530
|
+
|
|
2531
|
+
// Activity item reference
|
|
2532
|
+
const activityItem = PayloadItem.create({
|
|
2533
|
+
name: 'to',
|
|
2534
|
+
itemRef: userRefB
|
|
2535
|
+
})
|
|
2536
|
+
```
|
|
2537
|
+
|
|
2538
|
+
## 13.4 Activity-Related APIs
|
|
2539
|
+
|
|
2540
|
+
### Activity.create()
|
|
2541
|
+
|
|
2542
|
+
Create business activity definition.
|
|
2543
|
+
|
|
2544
|
+
**Syntax**
|
|
2545
|
+
```typescript
|
|
2546
|
+
Activity.create(config: ActivityConfig): ActivityInstance
|
|
2547
|
+
```
|
|
2548
|
+
|
|
2549
|
+
**Parameters**
|
|
2550
|
+
- `config.name` (string, required): Activity name
|
|
2551
|
+
- `config.interactions` (Interaction[], optional): List of interactions in the activity
|
|
2552
|
+
- `config.transfers` (Transfer[], optional): List of state transfers
|
|
2553
|
+
- `config.groups` (ActivityGroup[], optional): List of activity groups
|
|
2554
|
+
- `config.gateways` (Gateway[], optional): List of gateways
|
|
2555
|
+
- `config.events` (Event[], optional): List of events
|
|
2556
|
+
|
|
2557
|
+
**Examples**
|
|
2558
|
+
```typescript
|
|
2559
|
+
const OrderProcessActivity = Activity.create({
|
|
2560
|
+
name: 'OrderProcess',
|
|
2561
|
+
interactions: [
|
|
2562
|
+
CreateOrderInteraction,
|
|
2563
|
+
ConfirmOrderInteraction,
|
|
2564
|
+
PayOrderInteraction,
|
|
2565
|
+
ShipOrderInteraction
|
|
2566
|
+
],
|
|
2567
|
+
transfers: [
|
|
2568
|
+
Transfer.create({
|
|
2569
|
+
name: 'createToConfirm',
|
|
2570
|
+
source: CreateOrderInteraction,
|
|
2571
|
+
target: ConfirmOrderInteraction
|
|
2572
|
+
}),
|
|
2573
|
+
Transfer.create({
|
|
2574
|
+
name: 'confirmToPay',
|
|
2575
|
+
source: ConfirmOrderInteraction,
|
|
2576
|
+
target: PayOrderInteraction
|
|
2577
|
+
})
|
|
2578
|
+
]
|
|
2579
|
+
})
|
|
2580
|
+
```
|
|
2581
|
+
|
|
2582
|
+
### Transfer.create()
|
|
2583
|
+
|
|
2584
|
+
Create activity state transfer.
|
|
2585
|
+
|
|
2586
|
+
**Syntax**
|
|
2587
|
+
```typescript
|
|
2588
|
+
Transfer.create(config: TransferConfig): TransferInstance
|
|
2589
|
+
```
|
|
2590
|
+
|
|
2591
|
+
**Parameters**
|
|
2592
|
+
- `config.name` (string, required): Transfer name
|
|
2593
|
+
- `config.source` (Interaction|ActivityGroup|Gateway, required): Source node
|
|
2594
|
+
- `config.target` (Interaction|ActivityGroup|Gateway, required): Target node
|
|
2595
|
+
|
|
2596
|
+
**Examples**
|
|
2597
|
+
```typescript
|
|
2598
|
+
const ApprovalTransfer = Transfer.create({
|
|
2599
|
+
name: 'submitToApprove',
|
|
2600
|
+
source: SubmitApplicationInteraction,
|
|
2601
|
+
target: ApproveApplicationInteraction
|
|
2602
|
+
})
|
|
2603
|
+
```
|
|
2604
|
+
|
|
2605
|
+
### Condition.create()
|
|
2606
|
+
|
|
2607
|
+
Create interaction execution condition. Conditions are used to determine whether an interaction can be executed based on dynamic runtime checks.
|
|
2608
|
+
|
|
2609
|
+
**Syntax**
|
|
2610
|
+
```typescript
|
|
2611
|
+
Condition.create(config: ConditionConfig): ConditionInstance
|
|
2612
|
+
```
|
|
2613
|
+
|
|
2614
|
+
**Parameters**
|
|
2615
|
+
- `config.name` (string, optional): Condition name for debugging and error messages
|
|
2616
|
+
- `config.content` (function, required): Async condition check function with signature:
|
|
2617
|
+
```typescript
|
|
2618
|
+
async function(this: Controller, event: InteractionEventArgs): Promise<boolean>
|
|
2619
|
+
```
|
|
2620
|
+
|
|
2621
|
+
**Function Context**
|
|
2622
|
+
The `content` function is called with:
|
|
2623
|
+
- `this`: The Controller instance, providing access to system storage and other services
|
|
2624
|
+
- `event`: The interaction event containing:
|
|
2625
|
+
- `event.user`: The user executing the interaction
|
|
2626
|
+
- `event.payload`: The interaction payload data
|
|
2627
|
+
- Other event properties based on the interaction context
|
|
2628
|
+
|
|
2629
|
+
**Return Values**
|
|
2630
|
+
- `true`: Condition passes, interaction can proceed
|
|
2631
|
+
- `false`: Condition fails, interaction is rejected with "condition check failed" error
|
|
2632
|
+
- `undefined`: Treated as `true` with a warning (condition might not be implemented)
|
|
2633
|
+
- Thrown error: Caught and treated as `false`
|
|
2634
|
+
|
|
2635
|
+
**Examples**
|
|
2636
|
+
|
|
2637
|
+
```typescript
|
|
2638
|
+
// Basic condition - check user has enough credits
|
|
2639
|
+
const hasEnoughCredits = Condition.create({
|
|
2640
|
+
name: 'hasEnoughCredits',
|
|
2641
|
+
content: async function(this: Controller, event: any) {
|
|
2642
|
+
const user = await this.system.storage.findOne('User',
|
|
2643
|
+
MatchExp.atom({ key: 'id', value: ['=', event.user.id] }),
|
|
2644
|
+
undefined,
|
|
2645
|
+
['id', 'credits']
|
|
2646
|
+
)
|
|
2647
|
+
return user.credits >= 10
|
|
2648
|
+
}
|
|
2649
|
+
})
|
|
2650
|
+
|
|
2651
|
+
// Complex condition - check based on payload data
|
|
2652
|
+
const canAccessPremiumContent = Condition.create({
|
|
2653
|
+
name: 'canAccessPremiumContent',
|
|
2654
|
+
content: async function(this: Controller, event: any) {
|
|
2655
|
+
const user = await this.system.storage.findOne('User',
|
|
2656
|
+
MatchExp.atom({ key: 'id', value: ['=', event.user.id] }),
|
|
2657
|
+
undefined,
|
|
2658
|
+
['id', 'credits', 'subscriptionLevel']
|
|
2659
|
+
)
|
|
2660
|
+
const content = event.payload?.content
|
|
2661
|
+
|
|
2662
|
+
// Regular content is always accessible
|
|
2663
|
+
if (!content?.isPremium) return true
|
|
2664
|
+
|
|
2665
|
+
// Premium content requires subscription or credits
|
|
2666
|
+
return user.subscriptionLevel === 'premium' || user.credits >= content.creditCost
|
|
2667
|
+
}
|
|
2668
|
+
})
|
|
2669
|
+
|
|
2670
|
+
// System state condition
|
|
2671
|
+
const systemNotInMaintenance = Condition.create({
|
|
2672
|
+
name: 'systemNotInMaintenance',
|
|
2673
|
+
content: async function(this: Controller, event: any) {
|
|
2674
|
+
const system = await this.system.storage.findOne('System',
|
|
2675
|
+
undefined,
|
|
2676
|
+
undefined,
|
|
2677
|
+
['maintenanceMode']
|
|
2678
|
+
)
|
|
2679
|
+
return !system?.maintenanceMode
|
|
2680
|
+
}
|
|
2681
|
+
})
|
|
2682
|
+
|
|
2683
|
+
// Using condition in interaction
|
|
2684
|
+
const ViewContent = Interaction.create({
|
|
2685
|
+
name: 'viewContent',
|
|
2686
|
+
action: Action.create({ name: 'view' }),
|
|
2687
|
+
payload: Payload.create({
|
|
2688
|
+
items: [
|
|
2689
|
+
PayloadItem.create({ name: 'content', base: Content })
|
|
2690
|
+
]
|
|
2691
|
+
}),
|
|
2692
|
+
conditions: canAccessPremiumContent // Single condition
|
|
2693
|
+
})
|
|
2694
|
+
```
|
|
2695
|
+
|
|
2696
|
+
**Error Handling**
|
|
2697
|
+
- Conditions that return `false` trigger a `'condition check failed'` error
|
|
2698
|
+
- Throwing an error in the content function also results in permission denial
|
|
2699
|
+
- Use `event.error` to provide custom error messages
|
|
2700
|
+
|
|
2701
|
+
**Combining Conditions**
|
|
2702
|
+
Use `Conditions.create()` to combine multiple conditions with AND/OR logic:
|
|
2703
|
+
|
|
2704
|
+
```typescript
|
|
2705
|
+
import { Conditions, BoolExp } from 'interaqt'
|
|
2706
|
+
|
|
2707
|
+
const condition1 = Condition.create({
|
|
2708
|
+
name: 'hasCredits',
|
|
2709
|
+
content: async function(this: Controller, event) {
|
|
2710
|
+
return event.user.credits > 0
|
|
2711
|
+
}
|
|
2712
|
+
})
|
|
2713
|
+
|
|
2714
|
+
const condition2 = Condition.create({
|
|
2715
|
+
name: 'isVerified',
|
|
2716
|
+
content: async function(this: Controller, event) {
|
|
2717
|
+
return event.user.isVerified === true
|
|
2718
|
+
}
|
|
2719
|
+
})
|
|
2720
|
+
|
|
2721
|
+
// Combine with BoolExp
|
|
2722
|
+
const combinedConditions = Conditions.create({
|
|
2723
|
+
content: BoolExp.atom(condition1).and(BoolExp.atom(condition2))
|
|
2724
|
+
})
|
|
2725
|
+
|
|
2726
|
+
// Use in Interaction
|
|
2727
|
+
const PremiumAction = Interaction.create({
|
|
2728
|
+
name: 'premiumAction',
|
|
2729
|
+
action: Action.create({ name: 'premium' }),
|
|
2730
|
+
conditions: combinedConditions
|
|
2731
|
+
})
|
|
2732
|
+
```
|
|
2733
|
+
|
|
2734
|
+
**Usage Patterns**
|
|
2735
|
+
```typescript
|
|
2736
|
+
// Single condition - can be used directly
|
|
2737
|
+
conditions: AdminRole
|
|
2738
|
+
|
|
2739
|
+
// Combined conditions with AND
|
|
2740
|
+
conditions: Conditions.create({
|
|
2741
|
+
content: BoolExp.atom(AdminRole).and(BoolExp.atom(ActiveUser))
|
|
2742
|
+
})
|
|
2743
|
+
|
|
2744
|
+
// Combined conditions with OR
|
|
2745
|
+
conditions: Conditions.create({
|
|
2746
|
+
content: BoolExp.atom(AdminRole).or(BoolExp.atom(ModeratorRole))
|
|
2747
|
+
})
|
|
2748
|
+
|
|
2749
|
+
// Complex combinations
|
|
2750
|
+
conditions: Conditions.create({
|
|
2751
|
+
content: BoolExp.atom(AuthenticatedUser)
|
|
2752
|
+
.and(BoolExp.atom(ActiveUser))
|
|
2753
|
+
.and(
|
|
2754
|
+
BoolExp.atom(AdminRole).or(BoolExp.atom(ModeratorRole))
|
|
2755
|
+
)
|
|
2756
|
+
})
|
|
2757
|
+
```
|
|
2758
|
+
|
|
2759
|
+
**Best Practices**
|
|
2760
|
+
|
|
2761
|
+
1. **Always handle async operations properly**: Use await for all storage queries
|
|
2762
|
+
2. **Return explicit boolean values**: Avoid implicit conversions
|
|
2763
|
+
3. **Provide meaningful condition names**: Helps with debugging when conditions fail
|
|
2764
|
+
4. **Handle missing data gracefully**: Check for null/undefined before accessing properties
|
|
2765
|
+
5. **Keep conditions focused**: Each condition should check one specific rule
|
|
2766
|
+
6. **Use storage attributeQuery**: Only fetch the fields you need for performance
|
|
2767
|
+
|
|
2768
|
+
**Error Handling**
|
|
2769
|
+
|
|
2770
|
+
When a condition fails or throws an error, the interaction call returns:
|
|
2771
|
+
```typescript
|
|
2772
|
+
{
|
|
2773
|
+
error: {
|
|
2774
|
+
type: 'condition check failed',
|
|
2775
|
+
message: 'condition check failed'
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
```
|
|
2779
|
+
|
|
2780
|
+
## 13.5 System-Related APIs
|
|
2781
|
+
|
|
2782
|
+
### Controller
|
|
2783
|
+
|
|
2784
|
+
System controller that coordinates the work of various components.
|
|
2785
|
+
|
|
2786
|
+
**Constructor**
|
|
2787
|
+
```typescript
|
|
2788
|
+
new Controller({
|
|
2789
|
+
system: System,
|
|
2790
|
+
entities: EntityInstance[],
|
|
2791
|
+
relations: RelationInstance[],
|
|
2792
|
+
activities: ActivityInstance[],
|
|
2793
|
+
interactions: InteractionInstance[],
|
|
2794
|
+
dict?: DictionaryInstance[], // Note: This is for global dictionaries, NOT computations
|
|
2795
|
+
recordMutationSideEffects?: RecordMutationSideEffect[],
|
|
2796
|
+
ignorePermission?: boolean // Skip condition checks when true
|
|
2797
|
+
})
|
|
2798
|
+
```
|
|
2799
|
+
|
|
2800
|
+
⚠️ **IMPORTANT**: Controller does NOT accept a computations parameter. All computations should be defined within the `computation` field of Entity/Relation/Property definitions. The 6th parameter `dict` is for global dictionary definitions (Dictionary.create), not for computation definitions.
|
|
2801
|
+
|
|
2802
|
+
**Main Methods**
|
|
2803
|
+
|
|
2804
|
+
#### setup(install?: boolean)
|
|
2805
|
+
Initialize system.
|
|
2806
|
+
```typescript
|
|
2807
|
+
await controller.setup(true) // Create database tables
|
|
2808
|
+
```
|
|
2809
|
+
|
|
2810
|
+
#### callInteraction(interactionName: string, args: InteractionEventArgs)
|
|
2811
|
+
Call interaction.
|
|
2812
|
+
|
|
2813
|
+
**Note about ignorePermission**: When `controller.ignorePermission` is set to `true`, this method will bypass all condition checks, user validation, and payload validation defined in the interaction.
|
|
2814
|
+
|
|
2815
|
+
**Return Type**
|
|
2816
|
+
```typescript
|
|
2817
|
+
type InteractionCallResponse = {
|
|
2818
|
+
// Contains error information if the interaction failed
|
|
2819
|
+
error?: unknown
|
|
2820
|
+
|
|
2821
|
+
// For GET interactions: contains the retrieved data
|
|
2822
|
+
data?: unknown
|
|
2823
|
+
|
|
2824
|
+
// The interaction event that was processed
|
|
2825
|
+
event?: InteractionEvent
|
|
2826
|
+
|
|
2827
|
+
// Record mutations (create/update/delete) that occurred
|
|
2828
|
+
effects?: RecordMutationEvent[]
|
|
2829
|
+
|
|
2830
|
+
// Results from side effects defined in the interaction
|
|
2831
|
+
sideEffects?: {
|
|
2832
|
+
[effectName: string]: {
|
|
2833
|
+
result?: unknown
|
|
2834
|
+
error?: unknown
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
// Additional context (e.g., activityId for activity interactions)
|
|
2839
|
+
context?: {
|
|
2840
|
+
[key: string]: unknown
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
```
|
|
2844
|
+
|
|
2845
|
+
**Example**
|
|
2846
|
+
```typescript
|
|
2847
|
+
const result = await controller.callInteraction('createPost', {
|
|
2848
|
+
user: { id: 'user1' },
|
|
2849
|
+
payload: { postData: { title: 'Hello', content: 'World' } }
|
|
2850
|
+
})
|
|
2851
|
+
|
|
2852
|
+
// Check for errors
|
|
2853
|
+
if (result.error) {
|
|
2854
|
+
console.error('Interaction failed:', result.error)
|
|
2855
|
+
return
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
// Access created record ID from effects
|
|
2859
|
+
const createdPostId = result.effects?.[0]?.record?.id
|
|
2860
|
+
|
|
2861
|
+
// Check side effects
|
|
2862
|
+
if (result.sideEffects?.emailNotification?.error) {
|
|
2863
|
+
console.warn('Email notification failed')
|
|
2864
|
+
}
|
|
2865
|
+
```
|
|
2866
|
+
|
|
2867
|
+
#### callActivityInteraction(activityName: string, interactionName: string, activityId: string, args: InteractionEventArgs)
|
|
2868
|
+
Call interaction within activity.
|
|
2869
|
+
```typescript
|
|
2870
|
+
const result = await controller.callActivityInteraction(
|
|
2871
|
+
'OrderProcess',
|
|
2872
|
+
'confirmOrder',
|
|
2873
|
+
'activity-instance-1',
|
|
2874
|
+
{ user: { id: 'user1' }, payload: { orderData: {...} } }
|
|
2875
|
+
)
|
|
2876
|
+
```
|
|
2877
|
+
|
|
2878
|
+
### System
|
|
2879
|
+
|
|
2880
|
+
System abstract interface that defines basic services like storage and logging.
|
|
2881
|
+
|
|
2882
|
+
**Interface Definition**
|
|
2883
|
+
```typescript
|
|
2884
|
+
interface System {
|
|
2885
|
+
conceptClass: Map<string, ReturnType<typeof createClass>>
|
|
2886
|
+
storage: Storage
|
|
2887
|
+
logger: SystemLogger
|
|
2888
|
+
setup: (entities: Entity[], relations: Relation[], states: ComputationState[], install?: boolean) => Promise<any>
|
|
2889
|
+
}
|
|
2890
|
+
```
|
|
2891
|
+
|
|
2892
|
+
### Storage
|
|
2893
|
+
|
|
2894
|
+
Storage layer interface providing data persistence functionality.
|
|
2895
|
+
|
|
2896
|
+
**Interface Definition**
|
|
2897
|
+
```typescript
|
|
2898
|
+
interface Storage {
|
|
2899
|
+
// Entity mapping
|
|
2900
|
+
map: any
|
|
2901
|
+
|
|
2902
|
+
// Transaction operations
|
|
2903
|
+
beginTransaction: (transactionName?: string) => Promise<any>
|
|
2904
|
+
commitTransaction: (transactionName?: string) => Promise<any>
|
|
2905
|
+
rollbackTransaction: (transactionName?: string) => Promise<any>
|
|
2906
|
+
|
|
2907
|
+
// Dictionary-specific API
|
|
2908
|
+
dict: {
|
|
2909
|
+
get: (key: string) => Promise<any>
|
|
2910
|
+
set: (key: string, value: any) => Promise<void>
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
// General KV storage
|
|
2914
|
+
get: (itemName: string, id: string, initialValue?: any) => Promise<any>
|
|
2915
|
+
set: (itemName: string, id: string, value: any, events?: RecordMutationEvent[]) => Promise<any>
|
|
2916
|
+
|
|
2917
|
+
// Entity/Relation operations
|
|
2918
|
+
setup: (entities: EntityInstance[], relations: RelationInstance[], createTables?: boolean) => any
|
|
2919
|
+
findOne: (entityName: string, ...args: any[]) => Promise<any>
|
|
2920
|
+
find: (entityName: string, ...args: any[]) => Promise<any[]>
|
|
2921
|
+
create: (entityName: string, data: any, events?: RecordMutationEvent[]) => Promise<any>
|
|
2922
|
+
update: (entityName: string, ...args: any[]) => Promise<any>
|
|
2923
|
+
delete: (entityName: string, data: any, events?: RecordMutationEvent[]) => Promise<any>
|
|
2924
|
+
|
|
2925
|
+
// Relation-specific operations
|
|
2926
|
+
findOneRelationByName: (relationName: string, ...args: any[]) => Promise<any>
|
|
2927
|
+
findRelationByName: (relationName: string, ...args: any[]) => Promise<any>
|
|
2928
|
+
updateRelationByName: (relationName: string, matchExpressionData: any, rawData: any, events?: RecordMutationEvent[]) => Promise<any>
|
|
2929
|
+
removeRelationByName: (relationName: string, matchExpressionData: any, events?: RecordMutationEvent[]) => Promise<any>
|
|
2930
|
+
addRelationByNameById: (relationName: string, sourceEntityId: string, targetEntityId: string, rawData?: any, events?: RecordMutationEvent[]) => Promise<any>
|
|
2931
|
+
|
|
2932
|
+
// Utility methods
|
|
2933
|
+
getRelationName: (entityName: string, attributeName: string) => string
|
|
2934
|
+
getEntityName: (entityName: string, attributeName: string) => string
|
|
2935
|
+
listen: (callback: RecordMutationCallback) => void
|
|
2936
|
+
destroy: () => void
|
|
2937
|
+
}
|
|
2938
|
+
```
|
|
2939
|
+
|
|
2940
|
+
**Main Methods**
|
|
2941
|
+
|
|
2942
|
+
#### Transaction Operations
|
|
2943
|
+
|
|
2944
|
+
**beginTransaction(transactionName?: string)**
|
|
2945
|
+
Begin a database transaction.
|
|
2946
|
+
```typescript
|
|
2947
|
+
await storage.beginTransaction('updateOrder')
|
|
2948
|
+
```
|
|
2949
|
+
|
|
2950
|
+
**commitTransaction(transactionName?: string)**
|
|
2951
|
+
Commit a database transaction.
|
|
2952
|
+
```typescript
|
|
2953
|
+
await storage.commitTransaction('updateOrder')
|
|
2954
|
+
```
|
|
2955
|
+
|
|
2956
|
+
**rollbackTransaction(transactionName?: string)**
|
|
2957
|
+
Rollback a database transaction.
|
|
2958
|
+
```typescript
|
|
2959
|
+
await storage.rollbackTransaction('updateOrder')
|
|
2960
|
+
```
|
|
2961
|
+
|
|
2962
|
+
#### Entity/Relation Operations
|
|
2963
|
+
|
|
2964
|
+
🔴 **CRITICAL: Always specify attributeQuery parameter!**
|
|
2965
|
+
- Without `attributeQuery`, only the `id` field is returned
|
|
2966
|
+
- This is a common source of bugs in tests and applications
|
|
2967
|
+
- Always explicitly list all fields you need
|
|
2968
|
+
|
|
2969
|
+
🔴 **CRITICAL: Relations have source and target as nested entities!**
|
|
2970
|
+
When working with relations (not regular entities), remember that:
|
|
2971
|
+
- **Relations** have `source` and `target` fields that reference entities
|
|
2972
|
+
- These fields should be treated as **nested entities**, not simple values
|
|
2973
|
+
- In `matchExpression`: Use dot notation (`source.id`, `target.name`)
|
|
2974
|
+
- In `attributeQuery`: Use nested query syntax (`['source', { attributeQuery: [...] }]`)
|
|
2975
|
+
- This applies to ALL storage methods when querying relations: `find()`, `findOne()`, `findRelationByName()`, `findOneRelationByName()`
|
|
2976
|
+
|
|
2977
|
+
🔴 **CRITICAL: Always use Relation instance name when querying!**
|
|
2978
|
+
When using `storage.find()` or `storage.findOne()` to query relations:
|
|
2979
|
+
- **ALWAYS** use the name from the Relation instance: `storage.find(UserPostRelation.name, ...)`
|
|
2980
|
+
- **NEVER** hardcode the relation name: `storage.find('UserPostRelation', ...)` ❌
|
|
2981
|
+
- This is crucial because:
|
|
2982
|
+
1. Relation names can be manually specified in `Relation.create({ name: 'CustomName', ... })`
|
|
2983
|
+
2. If no name is specified, the framework auto-generates one (e.g., `UserPost` for User→Post relation)
|
|
2984
|
+
3. Using the instance name ensures you always get the correct name regardless of how it was defined
|
|
2985
|
+
|
|
2986
|
+
**Example:**
|
|
2987
|
+
```typescript
|
|
2988
|
+
// Define relation - name might be auto-generated or manually specified
|
|
2989
|
+
const UserPostRelation = Relation.create({
|
|
2990
|
+
source: User,
|
|
2991
|
+
sourceProperty: 'posts',
|
|
2992
|
+
target: Post,
|
|
2993
|
+
targetProperty: 'author',
|
|
2994
|
+
type: '1:n'
|
|
2995
|
+
// Note: no 'name' property, so it will be auto-generated as 'UserPost'
|
|
2996
|
+
})
|
|
2997
|
+
|
|
2998
|
+
// ✅ CORRECT: Always use the relation instance's name property
|
|
2999
|
+
const relations = await storage.find(UserPostRelation.name, ...) // Will use 'UserPost'
|
|
3000
|
+
|
|
3001
|
+
// ❌ WRONG: Never hardcode the relation name
|
|
3002
|
+
const relations = await storage.find('UserPost', ...) // Might break if name changes
|
|
3003
|
+
const relations = await storage.find('UserPostRelation', ...) // Wrong assumption
|
|
3004
|
+
```
|
|
3005
|
+
|
|
3006
|
+
**find(entityName: string, matchExpression?: MatchExpressionData, modifier?: ModifierData, attributeQuery?: AttributeQueryData)**
|
|
3007
|
+
Find multiple records matching the criteria.
|
|
3008
|
+
|
|
3009
|
+
**Parameters**
|
|
3010
|
+
- `entityName` (string): Name of the entity to query
|
|
3011
|
+
- `matchExpression` (MatchExpressionData, optional): Query conditions
|
|
3012
|
+
- `modifier` (ModifierData, optional): Query modifiers (limit, offset, orderBy, etc.)
|
|
3013
|
+
- `attributeQuery` (AttributeQueryData, optional but critical): Fields to retrieve
|
|
3014
|
+
|
|
3015
|
+
```typescript
|
|
3016
|
+
// ✅ CORRECT: Returns all specified fields
|
|
3017
|
+
const users = await storage.find('User',
|
|
3018
|
+
MatchExp.atom({ key: 'status', value: ['=', 'active'] }),
|
|
3019
|
+
{ limit: 10, orderBy: { createdAt: 'desc' } },
|
|
3020
|
+
['id', 'username', 'email', 'lastLoginDate']
|
|
3021
|
+
)
|
|
3022
|
+
```
|
|
3023
|
+
|
|
3024
|
+
**🔴 CRITICAL: Querying Relations with source/target Fields**
|
|
3025
|
+
|
|
3026
|
+
When querying relations that have `source` and `target` fields, these fields should be treated as related entities:
|
|
3027
|
+
|
|
3028
|
+
1. **In matchExpression - Use dot notation for nested properties:**
|
|
3029
|
+
```typescript
|
|
3030
|
+
// ✅ CORRECT: Use relation instance name and dot notation for nested properties
|
|
3031
|
+
const relations = await storage.find(UserPostRelation.name,
|
|
3032
|
+
MatchExp.atom({ key: 'source.id', value: ['=', userId] })
|
|
3033
|
+
.and({ key: 'target.status', value: ['=', 'published'] }),
|
|
3034
|
+
undefined,
|
|
3035
|
+
['id', 'createdAt']
|
|
3036
|
+
)
|
|
3037
|
+
|
|
3038
|
+
// ❌ WRONG: Don't hardcode relation names or compare source/target directly
|
|
3039
|
+
const relations = await storage.find('UserPostRelation', // Don't hardcode!
|
|
3040
|
+
MatchExp.atom({ key: 'source', value: ['=', userId] }), // This won't work!
|
|
3041
|
+
undefined,
|
|
3042
|
+
['id']
|
|
3043
|
+
)
|
|
3044
|
+
```
|
|
3045
|
+
|
|
3046
|
+
2. **In attributeQuery - Use nested query syntax for source/target:**
|
|
3047
|
+
```typescript
|
|
3048
|
+
// ✅ CORRECT: Use relation instance name and nested attributeQuery
|
|
3049
|
+
const relations = await storage.find(UserPostRelation.name,
|
|
3050
|
+
undefined,
|
|
3051
|
+
undefined,
|
|
3052
|
+
[
|
|
3053
|
+
'id',
|
|
3054
|
+
'createdAt', // Relation's own property
|
|
3055
|
+
['source', { attributeQuery: ['id', 'name', 'email'] }], // Source entity fields
|
|
3056
|
+
['target', { attributeQuery: ['id', 'title', 'status'] }] // Target entity fields
|
|
3057
|
+
]
|
|
3058
|
+
)
|
|
3059
|
+
|
|
3060
|
+
// ❌ WRONG: Hardcoded name and incorrect attributeQuery
|
|
3061
|
+
const relations = await storage.find('UserPostRelation', // Don't hardcode!
|
|
3062
|
+
undefined,
|
|
3063
|
+
undefined,
|
|
3064
|
+
['id', 'source', 'target'] // This only returns entity references, not data!
|
|
3065
|
+
)
|
|
3066
|
+
```
|
|
3067
|
+
|
|
3068
|
+
**🔴 CRITICAL: Querying and Retrieving Related Entities**
|
|
3069
|
+
|
|
3070
|
+
When working with related entities, you must use the correct syntax:
|
|
3071
|
+
|
|
3072
|
+
1. **In matchExpression - Query by nested property path:**
|
|
3073
|
+
```typescript
|
|
3074
|
+
// ✅ CORRECT: Use dot notation to access related entity's properties
|
|
3075
|
+
const students = await storage.find('Student',
|
|
3076
|
+
MatchExp.atom({ key: 'teacher.id', value: ['=', teacherId] }),
|
|
3077
|
+
undefined,
|
|
3078
|
+
['id', 'name']
|
|
3079
|
+
)
|
|
3080
|
+
|
|
3081
|
+
// ❌ WRONG: Cannot compare entity object directly
|
|
3082
|
+
const students = await storage.find('Student',
|
|
3083
|
+
MatchExp.atom({ key: 'teacher', value: ['=', teacherId] }), // This won't work!
|
|
3084
|
+
undefined,
|
|
3085
|
+
['id', 'name']
|
|
3086
|
+
)
|
|
3087
|
+
```
|
|
3088
|
+
|
|
3089
|
+
2. **In attributeQuery - Retrieve related entity fields:**
|
|
3090
|
+
```typescript
|
|
3091
|
+
// ✅ CORRECT: Use nested attributeQuery to fetch related entity fields
|
|
3092
|
+
const students = await storage.find('Student',
|
|
3093
|
+
undefined,
|
|
3094
|
+
undefined,
|
|
3095
|
+
[
|
|
3096
|
+
'id',
|
|
3097
|
+
'name',
|
|
3098
|
+
['teacher', { attributeQuery: ['id', 'name', 'email'] }] // Nested query for related entity
|
|
3099
|
+
]
|
|
3100
|
+
)
|
|
3101
|
+
|
|
3102
|
+
// ❌ WRONG: This only returns the reference, not the actual data
|
|
3103
|
+
const students = await storage.find('Student',
|
|
3104
|
+
undefined,
|
|
3105
|
+
undefined,
|
|
3106
|
+
['id', 'name', 'teacher'] // This won't fetch teacher's data!
|
|
3107
|
+
)
|
|
3108
|
+
```
|
|
3109
|
+
|
|
3110
|
+
**Complete Example:**
|
|
3111
|
+
```typescript
|
|
3112
|
+
// Find all students of a specific teacher with teacher details
|
|
3113
|
+
const students = await storage.find('Student',
|
|
3114
|
+
MatchExp.atom({ key: 'teacher.id', value: ['=', 'teacher123'] }),
|
|
3115
|
+
{ limit: 20 },
|
|
3116
|
+
[
|
|
3117
|
+
'id',
|
|
3118
|
+
'name',
|
|
3119
|
+
'grade',
|
|
3120
|
+
['teacher', {
|
|
3121
|
+
attributeQuery: ['id', 'name', 'department']
|
|
3122
|
+
}],
|
|
3123
|
+
['courses', {
|
|
3124
|
+
attributeQuery: ['id', 'title', 'credits']
|
|
3125
|
+
}]
|
|
3126
|
+
]
|
|
3127
|
+
)
|
|
3128
|
+
```
|
|
3129
|
+
|
|
3130
|
+
**findOne(entityName: string, matchExpression?: MatchExpressionData, modifier?: ModifierData, attributeQuery?: AttributeQueryData)**
|
|
3131
|
+
Find a single record matching the criteria.
|
|
3132
|
+
|
|
3133
|
+
**Parameters**
|
|
3134
|
+
- Same as `find()` but returns only the first result
|
|
3135
|
+
|
|
3136
|
+
```typescript
|
|
3137
|
+
// ✅ CORRECT: Returns all specified fields
|
|
3138
|
+
const user = await storage.findOne('User',
|
|
3139
|
+
MatchExp.atom({ key: 'email', value: ['=', 'user@example.com'] }),
|
|
3140
|
+
undefined,
|
|
3141
|
+
['id', 'name', 'email', 'role', 'createdAt']
|
|
3142
|
+
)
|
|
3143
|
+
```
|
|
3144
|
+
|
|
3145
|
+
**🔴 The same rules for related entities apply to findOne:**
|
|
3146
|
+
```typescript
|
|
3147
|
+
// ✅ CORRECT: Query and retrieve related entity
|
|
3148
|
+
const student = await storage.findOne('Student',
|
|
3149
|
+
MatchExp.atom({ key: 'teacher.id', value: ['=', teacherId] }), // Use dot notation in query
|
|
3150
|
+
undefined,
|
|
3151
|
+
[
|
|
3152
|
+
'id',
|
|
3153
|
+
'name',
|
|
3154
|
+
['teacher', { attributeQuery: ['id', 'name'] }] // Use nested attributeQuery for retrieval
|
|
3155
|
+
]
|
|
3156
|
+
)
|
|
3157
|
+
```
|
|
3158
|
+
|
|
3159
|
+
**🔴 When using findOne with Relations:**
|
|
3160
|
+
```typescript
|
|
3161
|
+
// ✅ CORRECT: Use relation instance name and dot notation
|
|
3162
|
+
const relation = await storage.findOne(UserPostRelation.name,
|
|
3163
|
+
MatchExp.atom({ key: 'source.id', value: ['=', userId] })
|
|
3164
|
+
.and({ key: 'target.id', value: ['=', postId] }),
|
|
3165
|
+
undefined,
|
|
3166
|
+
[
|
|
3167
|
+
'id',
|
|
3168
|
+
['source', { attributeQuery: ['id', 'name'] }],
|
|
3169
|
+
['target', { attributeQuery: ['id', 'title'] }]
|
|
3170
|
+
]
|
|
3171
|
+
)
|
|
3172
|
+
|
|
3173
|
+
// ❌ WRONG: Don't hardcode names or query source/target directly
|
|
3174
|
+
const relation = await storage.findOne('UserPostRelation', // Don't hardcode!
|
|
3175
|
+
MatchExp.atom({ key: 'source', value: ['=', userId] }), // Won't work!
|
|
3176
|
+
undefined,
|
|
3177
|
+
['id', 'source', 'target'] // Won't fetch entity data!
|
|
3178
|
+
)
|
|
3179
|
+
```
|
|
3180
|
+
|
|
3181
|
+
**create(entityName: string, data: any, events?: RecordMutationEvent[])**
|
|
3182
|
+
Create a new record.
|
|
3183
|
+
|
|
3184
|
+
**Parameters**
|
|
3185
|
+
- `entityName` (string): Name of the entity
|
|
3186
|
+
- `data` (any): Entity data (do NOT include id field)
|
|
3187
|
+
- `events` (RecordMutationEvent[], optional): Mutation events array
|
|
3188
|
+
|
|
3189
|
+
```typescript
|
|
3190
|
+
const user = await storage.create('User', {
|
|
3191
|
+
username: 'john',
|
|
3192
|
+
email: 'john@example.com',
|
|
3193
|
+
role: 'user'
|
|
3194
|
+
})
|
|
3195
|
+
// Returns created record with generated id
|
|
3196
|
+
```
|
|
3197
|
+
|
|
3198
|
+
**update(entityName: string, matchExpression: MatchExpressionData, data: any, events?: RecordMutationEvent[])**
|
|
3199
|
+
Update existing records.
|
|
3200
|
+
|
|
3201
|
+
**Parameters**
|
|
3202
|
+
- `entityName` (string): Name of the entity
|
|
3203
|
+
- `matchExpression` (MatchExpressionData): Which records to update
|
|
3204
|
+
- `data` (any): Fields to update
|
|
3205
|
+
- `events` (RecordMutationEvent[], optional): Mutation events array
|
|
3206
|
+
|
|
3207
|
+
```typescript
|
|
3208
|
+
await storage.update('User',
|
|
3209
|
+
MatchExp.atom({ key: 'id', value: ['=', userId] }),
|
|
3210
|
+
{ status: 'inactive', lastModified: Math.floor(Date.now()/1000) } // In seconds
|
|
3211
|
+
)
|
|
3212
|
+
```
|
|
3213
|
+
|
|
3214
|
+
**delete(entityName: string, matchExpression: MatchExpressionData, events?: RecordMutationEvent[])**
|
|
3215
|
+
Delete records.
|
|
3216
|
+
|
|
3217
|
+
**Parameters**
|
|
3218
|
+
- `entityName` (string): Name of the entity
|
|
3219
|
+
- `matchExpression` (MatchExpressionData): Which records to delete
|
|
3220
|
+
- `events` (RecordMutationEvent[], optional): Mutation events array
|
|
3221
|
+
|
|
3222
|
+
```typescript
|
|
3223
|
+
await storage.delete('User',
|
|
3224
|
+
MatchExp.atom({ key: 'id', value: ['=', userId] })
|
|
3225
|
+
)
|
|
3226
|
+
```
|
|
3227
|
+
|
|
3228
|
+
#### Relation-Specific Operations
|
|
3229
|
+
|
|
3230
|
+
**🔴 IMPORTANT: Relations have source and target fields that should be treated as related entities**
|
|
3231
|
+
|
|
3232
|
+
When using relation-specific operations, the same rules apply for source/target fields:
|
|
3233
|
+
- In `matchExpression`: Use dot notation like `source.id` or `target.status`
|
|
3234
|
+
- In `attributeQuery`: Use nested query syntax like `['source', { attributeQuery: [...] }]`
|
|
3235
|
+
|
|
3236
|
+
**findRelationByName(relationName: string, matchExpression?: MatchExpressionData, modifier?: ModifierData, attributeQuery?: AttributeQueryData)**
|
|
3237
|
+
Find relation records by relation name.
|
|
3238
|
+
|
|
3239
|
+
```typescript
|
|
3240
|
+
// ✅ CORRECT: Use relation instance name, dot notation in matchExpression and nested query in attributeQuery
|
|
3241
|
+
const userPosts = await storage.findRelationByName(UserPostRelation.name,
|
|
3242
|
+
MatchExp.atom({ key: 'source.id', value: ['=', userId] })
|
|
3243
|
+
.and({ key: 'target.status', value: ['=', 'published'] }),
|
|
3244
|
+
{ limit: 10 },
|
|
3245
|
+
[
|
|
3246
|
+
'id',
|
|
3247
|
+
'createdAt',
|
|
3248
|
+
['source', { attributeQuery: ['id', 'name'] }],
|
|
3249
|
+
['target', { attributeQuery: ['id', 'title', 'status'] }]
|
|
3250
|
+
]
|
|
3251
|
+
)
|
|
3252
|
+
|
|
3253
|
+
// ❌ WRONG: Don't hardcode relation names or use source/target directly in matchExpression
|
|
3254
|
+
const userPosts = await storage.findRelationByName('UserPostRelation', // Don't hardcode!
|
|
3255
|
+
MatchExp.atom({ key: 'source', value: ['=', userId] }), // Won't work!
|
|
3256
|
+
undefined,
|
|
3257
|
+
['id', 'source', 'target'] // Won't fetch entity data!
|
|
3258
|
+
)
|
|
3259
|
+
```
|
|
3260
|
+
|
|
3261
|
+
**findOneRelationByName(relationName: string, matchExpression: MatchExpressionData, modifier?: ModifierData, attributeQuery?: AttributeQueryData)**
|
|
3262
|
+
Find a single relation record by relation name.
|
|
3263
|
+
|
|
3264
|
+
```typescript
|
|
3265
|
+
// ✅ CORRECT: Use relation instance name to query and fetch relation with entity data
|
|
3266
|
+
const relation = await storage.findOneRelationByName(UserPostRelation.name,
|
|
3267
|
+
MatchExp.atom({ key: 'source.id', value: ['=', userId] })
|
|
3268
|
+
.and({ key: 'target.id', value: ['=', postId] }),
|
|
3269
|
+
undefined,
|
|
3270
|
+
[
|
|
3271
|
+
'id',
|
|
3272
|
+
'createdAt',
|
|
3273
|
+
['source', { attributeQuery: ['id', 'name', 'email'] }],
|
|
3274
|
+
['target', { attributeQuery: ['id', 'title', 'content'] }]
|
|
3275
|
+
]
|
|
3276
|
+
)
|
|
3277
|
+
|
|
3278
|
+
// Simple case - just fetch by relation ID (still use instance name)
|
|
3279
|
+
const relation = await storage.findOneRelationByName(UserPostRelation.name,
|
|
3280
|
+
MatchExp.atom({ key: 'id', value: ['=', relationId] }),
|
|
3281
|
+
undefined,
|
|
3282
|
+
['*'] // This is OK for fetching all relation properties, but won't expand source/target
|
|
3283
|
+
)
|
|
3284
|
+
```
|
|
3285
|
+
|
|
3286
|
+
**addRelationByNameById(relationName: string, sourceEntityId: string, targetEntityId: string, data?: any, events?: RecordMutationEvent[])**
|
|
3287
|
+
Create a relation between two entities by their IDs.
|
|
3288
|
+
|
|
3289
|
+
```typescript
|
|
3290
|
+
// Create relation between user and post
|
|
3291
|
+
await storage.addRelationByNameById(UserPostRelation.name,
|
|
3292
|
+
userId,
|
|
3293
|
+
postId,
|
|
3294
|
+
{ createdAt: Math.floor(Date.now()/1000) } // Optional relation properties - in seconds
|
|
3295
|
+
)
|
|
3296
|
+
```
|
|
3297
|
+
|
|
3298
|
+
**updateRelationByName(relationName: string, matchExpression: MatchExpressionData, data: any, events?: RecordMutationEvent[])**
|
|
3299
|
+
Update relation properties (cannot update source/target).
|
|
3300
|
+
|
|
3301
|
+
```typescript
|
|
3302
|
+
// Update by relation ID
|
|
3303
|
+
await storage.updateRelationByName(UserPostRelation.name,
|
|
3304
|
+
MatchExp.atom({ key: 'id', value: ['=', relationId] }),
|
|
3305
|
+
{ priority: 'high' } // Only update relation properties
|
|
3306
|
+
)
|
|
3307
|
+
|
|
3308
|
+
// ✅ CORRECT: Use relation instance name and source/target properties
|
|
3309
|
+
await storage.updateRelationByName(UserPostRelation.name,
|
|
3310
|
+
MatchExp.atom({ key: 'source.id', value: ['=', userId] })
|
|
3311
|
+
.and({ key: 'target.status', value: ['=', 'draft'] }),
|
|
3312
|
+
{ reviewed: true }
|
|
3313
|
+
)
|
|
3314
|
+
|
|
3315
|
+
// ❌ WRONG: Don't hardcode relation names or use source/target directly
|
|
3316
|
+
await storage.updateRelationByName('UserPostRelation', // Don't hardcode!
|
|
3317
|
+
MatchExp.atom({ key: 'source', value: ['=', userId] }), // Won't work!
|
|
3318
|
+
{ reviewed: true }
|
|
3319
|
+
)
|
|
3320
|
+
```
|
|
3321
|
+
|
|
3322
|
+
**removeRelationByName(relationName: string, matchExpression: MatchExpressionData, events?: RecordMutationEvent[])**
|
|
3323
|
+
Remove relations.
|
|
3324
|
+
|
|
3325
|
+
```typescript
|
|
3326
|
+
// Remove by relation ID
|
|
3327
|
+
await storage.removeRelationByName(UserPostRelation.name,
|
|
3328
|
+
MatchExp.atom({ key: 'id', value: ['=', relationId] })
|
|
3329
|
+
)
|
|
3330
|
+
|
|
3331
|
+
// ✅ CORRECT: Use relation instance name with source/target properties
|
|
3332
|
+
await storage.removeRelationByName(UserPostRelation.name,
|
|
3333
|
+
MatchExp.atom({ key: 'source.id', value: ['=', userId] })
|
|
3334
|
+
.and({ key: 'target.id', value: ['=', postId] })
|
|
3335
|
+
)
|
|
3336
|
+
|
|
3337
|
+
// ❌ WRONG: Don't hardcode relation names or use source/target directly
|
|
3338
|
+
await storage.removeRelationByName('UserPostRelation', // Don't hardcode!
|
|
3339
|
+
MatchExp.atom({ key: 'target', value: ['=', postId] }) // Won't work!
|
|
3340
|
+
)
|
|
3341
|
+
```
|
|
3342
|
+
|
|
3343
|
+
#### Dictionary Operations
|
|
3344
|
+
|
|
3345
|
+
**dict.get(key: string)**
|
|
3346
|
+
Get value from Dictionary storage.
|
|
3347
|
+
|
|
3348
|
+
```typescript
|
|
3349
|
+
// Get dictionary value
|
|
3350
|
+
const userCount = await storage.dict.get('userCount')
|
|
3351
|
+
|
|
3352
|
+
// Returns undefined if key doesn't exist
|
|
3353
|
+
const config = await storage.dict.get('systemConfig')
|
|
3354
|
+
if (config === undefined) {
|
|
3355
|
+
// Handle missing value
|
|
3356
|
+
}
|
|
3357
|
+
```
|
|
3358
|
+
|
|
3359
|
+
**dict.set(key: string, value: any)**
|
|
3360
|
+
Set value in Dictionary storage.
|
|
3361
|
+
|
|
3362
|
+
```typescript
|
|
3363
|
+
// Set dictionary value
|
|
3364
|
+
await storage.dict.set('userCount', 100)
|
|
3365
|
+
|
|
3366
|
+
// Store complex objects
|
|
3367
|
+
await storage.dict.set('systemConfig', {
|
|
3368
|
+
maxUsers: 1000,
|
|
3369
|
+
maintenanceMode: false,
|
|
3370
|
+
features: ['chat', 'notifications']
|
|
3371
|
+
})
|
|
3372
|
+
```
|
|
3373
|
+
|
|
3374
|
+
**Note**: The Dictionary API is specifically designed for storing global state values that are defined as Dictionary instances in the system. It provides a cleaner, more focused API compared to the general KV storage operations.
|
|
3375
|
+
|
|
3376
|
+
#### KV Storage Operations
|
|
3377
|
+
|
|
3378
|
+
**get(itemName: string, id: string, initialValue?: any)**
|
|
3379
|
+
Get value from general key-value storage.
|
|
3380
|
+
|
|
3381
|
+
```typescript
|
|
3382
|
+
// Get value with default
|
|
3383
|
+
const maxUsers = await storage.get('config', 'maxUsers', 100)
|
|
3384
|
+
```
|
|
3385
|
+
|
|
3386
|
+
**set(itemName: string, id: string, value: any, events?: RecordMutationEvent[])**
|
|
3387
|
+
Set value in general key-value storage.
|
|
3388
|
+
|
|
3389
|
+
```typescript
|
|
3390
|
+
// Set configuration value
|
|
3391
|
+
await storage.set('config', 'maxUsers', 1000)
|
|
3392
|
+
|
|
3393
|
+
// Store complex objects
|
|
3394
|
+
await storage.set('cache', 'userPreferences', {
|
|
3395
|
+
theme: 'dark',
|
|
3396
|
+
language: 'en',
|
|
3397
|
+
notifications: true
|
|
3398
|
+
})
|
|
3399
|
+
```
|
|
3400
|
+
|
|
3401
|
+
**Note**: The general KV storage operations (`get`/`set`) are for arbitrary key-value pairs. For Dictionary entities specifically, prefer using the `dict` API.
|
|
3402
|
+
|
|
3403
|
+
#### Utility Methods
|
|
3404
|
+
|
|
3405
|
+
**getRelationName(entityName: string, attributeName: string)**
|
|
3406
|
+
Get the internal relation name for an entity's relation property.
|
|
3407
|
+
|
|
3408
|
+
```typescript
|
|
3409
|
+
const relationName = storage.getRelationName('User', 'posts')
|
|
3410
|
+
// Returns something like 'User_posts_author_Post'
|
|
3411
|
+
```
|
|
3412
|
+
|
|
3413
|
+
**getEntityName(entityName: string, attributeName: string)**
|
|
3414
|
+
Get the target entity name for a relation property.
|
|
3415
|
+
|
|
3416
|
+
```typescript
|
|
3417
|
+
const targetEntity = storage.getEntityName('User', 'posts')
|
|
3418
|
+
// Returns 'Post'
|
|
3419
|
+
```
|
|
3420
|
+
|
|
3421
|
+
**listen(callback: RecordMutationCallback)**
|
|
3422
|
+
Register a callback to listen for record mutations.
|
|
3423
|
+
|
|
3424
|
+
```typescript
|
|
3425
|
+
storage.listen(async (events) => {
|
|
3426
|
+
for (const event of events) {
|
|
3427
|
+
console.log(`${event.type} on ${event.recordName}`, event.record)
|
|
3428
|
+
}
|
|
3429
|
+
})
|
|
3430
|
+
```
|
|
3431
|
+
|
|
3432
|
+
#### AttributeQueryData Format
|
|
3433
|
+
|
|
3434
|
+
AttributeQuery specifies which fields to retrieve and supports nested queries for relations:
|
|
3435
|
+
|
|
3436
|
+
```typescript
|
|
3437
|
+
type AttributeQueryData = (string | [string, { attributeQuery?: AttributeQueryData }])[]
|
|
3438
|
+
|
|
3439
|
+
// Examples:
|
|
3440
|
+
// Simple fields
|
|
3441
|
+
['id', 'name', 'email']
|
|
3442
|
+
|
|
3443
|
+
// All fields
|
|
3444
|
+
['*']
|
|
3445
|
+
|
|
3446
|
+
// Nested relation query
|
|
3447
|
+
[
|
|
3448
|
+
'id',
|
|
3449
|
+
'name',
|
|
3450
|
+
['posts', {
|
|
3451
|
+
attributeQuery: ['title', 'status', 'createdAt']
|
|
3452
|
+
}]
|
|
3453
|
+
]
|
|
3454
|
+
|
|
3455
|
+
// Multi-level nesting
|
|
3456
|
+
[
|
|
3457
|
+
'id',
|
|
3458
|
+
['posts', {
|
|
3459
|
+
attributeQuery: [
|
|
3460
|
+
'title',
|
|
3461
|
+
['comments', {
|
|
3462
|
+
attributeQuery: ['content', 'author']
|
|
3463
|
+
}]
|
|
3464
|
+
]
|
|
3465
|
+
}]
|
|
3466
|
+
]
|
|
3467
|
+
```
|
|
3468
|
+
|
|
3469
|
+
#### ModifierData Format
|
|
3470
|
+
|
|
3471
|
+
Modifiers control query behavior:
|
|
3472
|
+
|
|
3473
|
+
```typescript
|
|
3474
|
+
type ModifierData = {
|
|
3475
|
+
limit?: number
|
|
3476
|
+
offset?: number
|
|
3477
|
+
orderBy?: {
|
|
3478
|
+
[field: string]: 'asc' | 'desc'
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
// Example
|
|
3483
|
+
{
|
|
3484
|
+
limit: 20,
|
|
3485
|
+
offset: 40,
|
|
3486
|
+
orderBy: {
|
|
3487
|
+
createdAt: 'desc',
|
|
3488
|
+
priority: 'asc'
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
```
|
|
3492
|
+
|
|
3493
|
+
## 13.6 Utility Function APIs
|
|
3494
|
+
|
|
3495
|
+
### MatchExp
|
|
3496
|
+
|
|
3497
|
+
Query expression builder for constructing complex query conditions.
|
|
3498
|
+
|
|
3499
|
+
#### MatchExp.atom(condition: MatchAtom)
|
|
3500
|
+
Create atomic query condition.
|
|
3501
|
+
|
|
3502
|
+
**Parameters**
|
|
3503
|
+
- `condition.key` (string): Field name, supports dot notation like 'user.profile.name'
|
|
3504
|
+
- `condition.value` ([string, any]): Array of operator and value
|
|
3505
|
+
- `condition.isReferenceValue` (boolean, optional): Whether it's a reference value
|
|
3506
|
+
|
|
3507
|
+
**Supported Operators**
|
|
3508
|
+
- `['=', value]`: Equals
|
|
3509
|
+
- `['!=', value]`: Not equals
|
|
3510
|
+
- `['>', value]`: Greater than
|
|
3511
|
+
- `['<', value]`: Less than
|
|
3512
|
+
- `['>=', value]`: Greater than or equal
|
|
3513
|
+
- `['<=', value]`: Less than or equal
|
|
3514
|
+
- `['like', pattern]`: Pattern matching
|
|
3515
|
+
- `['in', array]`: In array
|
|
3516
|
+
- `['between', [min, max]]`: In range
|
|
3517
|
+
- `['not', null]`: Not null
|
|
3518
|
+
|
|
3519
|
+
**Examples**
|
|
3520
|
+
```typescript
|
|
3521
|
+
// Basic condition
|
|
3522
|
+
const condition1 = MatchExp.atom({
|
|
3523
|
+
key: 'status',
|
|
3524
|
+
value: ['=', 'active']
|
|
3525
|
+
})
|
|
3526
|
+
|
|
3527
|
+
// Range query
|
|
3528
|
+
const condition2 = MatchExp.atom({
|
|
3529
|
+
key: 'age',
|
|
3530
|
+
value: ['between', [18, 65]]
|
|
3531
|
+
})
|
|
3532
|
+
|
|
3533
|
+
// Relational query
|
|
3534
|
+
const condition3 = MatchExp.atom({
|
|
3535
|
+
key: 'user.profile.city',
|
|
3536
|
+
value: ['=', 'Beijing']
|
|
3537
|
+
})
|
|
3538
|
+
|
|
3539
|
+
// Combined conditions
|
|
3540
|
+
const complexCondition = MatchExp.atom({
|
|
3541
|
+
key: 'status',
|
|
3542
|
+
value: ['=', 'active']
|
|
3543
|
+
}).and({
|
|
3544
|
+
key: 'age',
|
|
3545
|
+
value: ['>', 18]
|
|
3546
|
+
}).or({
|
|
3547
|
+
key: 'vip',
|
|
3548
|
+
value: ['=', true]
|
|
3549
|
+
})
|
|
3550
|
+
```
|
|
3551
|
+
|
|
3552
|
+
#### MatchExp.fromObject(condition: Object)
|
|
3553
|
+
Create query condition from object (all conditions connected with AND).
|
|
3554
|
+
|
|
3555
|
+
```typescript
|
|
3556
|
+
const condition = MatchExp.fromObject({
|
|
3557
|
+
status: 'active',
|
|
3558
|
+
age: 25,
|
|
3559
|
+
city: 'Beijing'
|
|
3560
|
+
})
|
|
3561
|
+
// Equivalent to: status='active' AND age=25 AND city='Beijing'
|
|
3562
|
+
```
|
|
3563
|
+
|
|
3564
|
+
### BoolExp
|
|
3565
|
+
|
|
3566
|
+
Boolean expression builder for constructing complex logical expressions.
|
|
3567
|
+
|
|
3568
|
+
#### BoolExp.atom(data: T)
|
|
3569
|
+
Create atomic expression.
|
|
3570
|
+
|
|
3571
|
+
```typescript
|
|
3572
|
+
const expr1 = BoolExp.atom({ condition: 'isActive' })
|
|
3573
|
+
const expr2 = BoolExp.atom({ condition: 'isAdmin' })
|
|
3574
|
+
|
|
3575
|
+
// Combined expression
|
|
3576
|
+
const combined = expr1.and(expr2).or({ condition: 'isOwner' })
|
|
3577
|
+
```
|
|
3578
|
+
|
|
3579
|
+
## 13.5 Query APIs
|
|
3580
|
+
|
|
3581
|
+
### Conditions
|
|
3582
|
+
|
|
3583
|
+
Conditions are used to determine whether an interaction can be executed based on dynamic runtime checks.
|
|
3584
|
+
|
|
3585
|
+
**Syntax**
|
|
3586
|
+
```typescript
|
|
3587
|
+
Conditions.create(config: ConditionsConfig): ConditionsInstance
|
|
3588
|
+
```
|
|
3589
|
+
|
|
3590
|
+
**Parameters**
|
|
3591
|
+
- `config.content` (BoolExp, required): Boolean expression containing conditions combined with AND/OR logic
|
|
3592
|
+
|
|
3593
|
+
**Examples**
|
|
3594
|
+
```typescript
|
|
3595
|
+
import { Conditions, BoolExp } from 'interaqt'
|
|
3596
|
+
|
|
3597
|
+
const condition1 = Condition.create({
|
|
3598
|
+
name: 'hasCredits',
|
|
3599
|
+
content: async function(this: Controller, event) {
|
|
3600
|
+
return event.user.credits > 0
|
|
3601
|
+
}
|
|
3602
|
+
})
|
|
3603
|
+
|
|
3604
|
+
const condition2 = Condition.create({
|
|
3605
|
+
name: 'isVerified',
|
|
3606
|
+
content: async function(this: Controller, event) {
|
|
3607
|
+
return event.user.isVerified === true
|
|
3608
|
+
}
|
|
3609
|
+
})
|
|
3610
|
+
|
|
3611
|
+
// Combine with BoolExp
|
|
3612
|
+
const combinedConditions = Conditions.create({
|
|
3613
|
+
content: BoolExp.atom(condition1).and(BoolExp.atom(condition2))
|
|
3614
|
+
})
|
|
3615
|
+
|
|
3616
|
+
const MyInteraction = Interaction.create({
|
|
3617
|
+
name: 'myInteraction',
|
|
3618
|
+
action: Action.create({ name: 'execute' }),
|
|
3619
|
+
conditions: combinedConditions
|
|
3620
|
+
})
|
|
3621
|
+
```
|
|
3622
|
+
|
|
3623
|
+
### BoolExp
|
|
3624
|
+
|
|
3625
|
+
Boolean expression builder for constructing complex logical expressions.
|
|
3626
|
+
|
|
3627
|
+
#### BoolExp.atom(data: T)
|
|
3628
|
+
Create atomic expression.
|
|
3629
|
+
|
|
3630
|
+
```typescript
|
|
3631
|
+
const expr1 = BoolExp.atom({ condition: 'isActive' })
|
|
3632
|
+
const expr2 = BoolExp.atom({ condition: 'isAdmin' })
|
|
3633
|
+
|
|
3634
|
+
// Combined expression
|
|
3635
|
+
const combined = expr1.and(expr2).or({ condition: 'isOwner' })
|
|
3636
|
+
```
|
|
3637
|
+
|
|
3638
|
+
## Type Definitions
|
|
3639
|
+
|
|
3640
|
+
### Core Types
|
|
3641
|
+
|
|
3642
|
+
```typescript
|
|
3643
|
+
// Interaction event arguments
|
|
3644
|
+
type InteractionEventArgs = {
|
|
3645
|
+
user: { id: string, [key: string]: any }
|
|
3646
|
+
payload?: { [key: string]: any }
|
|
3647
|
+
[key: string]: any
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3650
|
+
// Record mutation event
|
|
3651
|
+
type RecordMutationEvent = {
|
|
3652
|
+
recordName: string
|
|
3653
|
+
type: 'create' | 'update' | 'delete'
|
|
3654
|
+
record?: EntityIdRef & { [key: string]: any }
|
|
3655
|
+
oldRecord?: EntityIdRef & { [key: string]: any }
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
// Entity reference
|
|
3659
|
+
type EntityIdRef = {
|
|
3660
|
+
id: string
|
|
3661
|
+
_rowId?: string
|
|
3662
|
+
[key: string]: any
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
// Attribute query data
|
|
3666
|
+
type AttributeQueryData = (string | [string, { attributeQuery?: AttributeQueryData }])[]
|
|
3667
|
+
```
|
|
3668
|
+
|
|
3669
|
+
### Computation-Related Types
|
|
3670
|
+
|
|
3671
|
+
```typescript
|
|
3672
|
+
// Computation context
|
|
3673
|
+
type DataContext = {
|
|
3674
|
+
type: 'global' | 'entity' | 'relation' | 'property'
|
|
3675
|
+
id: string | Entity | Relation
|
|
3676
|
+
host?: Entity | Relation
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
// Computation dependency
|
|
3680
|
+
type DataDep = {
|
|
3681
|
+
type: 'records' | 'property' | 'global'
|
|
3682
|
+
// For type: 'records' - the entity/relation to query globally
|
|
3683
|
+
source?: Entity | Relation | Dictionary
|
|
3684
|
+
// For type: 'property' - the relation property name to access
|
|
3685
|
+
property?: string
|
|
3686
|
+
attributeQuery?: AttributeQueryData
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
// Computation result
|
|
3690
|
+
type ComputationResult = any
|
|
3691
|
+
type ComputationResultPatch = {
|
|
3692
|
+
type: 'insert' | 'update' | 'delete'
|
|
3693
|
+
data?: any
|
|
3694
|
+
affectedId?: string
|
|
3695
|
+
}
|
|
3696
|
+
```
|
|
3697
|
+
|
|
3698
|
+
## Usage Examples
|
|
3699
|
+
|
|
3700
|
+
### Complete Blog System Example
|
|
3701
|
+
|
|
3702
|
+
```typescript
|
|
3703
|
+
import { Entity, Property, Relation, Interaction, Activity, Controller } from 'interaqt'
|
|
3704
|
+
|
|
3705
|
+
// 1. Define entities
|
|
3706
|
+
const User = Entity.create({
|
|
3707
|
+
name: 'User',
|
|
3708
|
+
properties: [
|
|
3709
|
+
Property.create({ name: 'username', type: 'string' }),
|
|
3710
|
+
Property.create({ name: 'email', type: 'string' }),
|
|
3711
|
+
Property.create({
|
|
3712
|
+
name: 'postCount',
|
|
3713
|
+
type: 'number',
|
|
3714
|
+
computation: Count.create({ property: 'posts' }) // Use property name from relation
|
|
3715
|
+
})
|
|
3716
|
+
]
|
|
3717
|
+
})
|
|
3718
|
+
|
|
3719
|
+
const Post = Entity.create({
|
|
3720
|
+
name: 'Post',
|
|
3721
|
+
properties: [
|
|
3722
|
+
Property.create({ name: 'title', type: 'string' }),
|
|
3723
|
+
Property.create({ name: 'content', type: 'string' }),
|
|
3724
|
+
Property.create({
|
|
3725
|
+
name: 'likeCount',
|
|
3726
|
+
type: 'number',
|
|
3727
|
+
computation: Count.create({ property: 'likes' }) // Use property name from relation
|
|
3728
|
+
})
|
|
3729
|
+
]
|
|
3730
|
+
})
|
|
3731
|
+
|
|
3732
|
+
// 2. Define relations
|
|
3733
|
+
const UserPostRelation = Relation.create({
|
|
3734
|
+
source: User,
|
|
3735
|
+
sourceProperty: 'posts',
|
|
3736
|
+
target: Post,
|
|
3737
|
+
targetProperty: 'author',
|
|
3738
|
+
type: '1:n'
|
|
3739
|
+
})
|
|
3740
|
+
|
|
3741
|
+
const PostLikeRelation = Relation.create({
|
|
3742
|
+
source: Post,
|
|
3743
|
+
sourceProperty: 'likes',
|
|
3744
|
+
target: User,
|
|
3745
|
+
targetProperty: 'likedPosts',
|
|
3746
|
+
type: 'n:n'
|
|
3747
|
+
})
|
|
3748
|
+
|
|
3749
|
+
// 3. Define interactions
|
|
3750
|
+
const CreatePostInteraction = Interaction.create({
|
|
3751
|
+
name: 'createPost',
|
|
3752
|
+
action: Action.create({ name: 'create' }),
|
|
3753
|
+
payload: Payload.create({
|
|
3754
|
+
items: [
|
|
3755
|
+
PayloadItem.create({
|
|
3756
|
+
name: 'postData',
|
|
3757
|
+
base: Post,
|
|
3758
|
+
required: true
|
|
3759
|
+
})
|
|
3760
|
+
]
|
|
3761
|
+
}),
|
|
3762
|
+
conditions: Condition.create({
|
|
3763
|
+
name: 'AuthenticatedUser',
|
|
3764
|
+
content: async function(event) {
|
|
3765
|
+
return event.user.id !== undefined
|
|
3766
|
+
}
|
|
3767
|
+
})
|
|
3768
|
+
})
|
|
3769
|
+
|
|
3770
|
+
const LikePostInteraction = Interaction.create({
|
|
3771
|
+
name: 'likePost',
|
|
3772
|
+
action: Action.create({ name: 'create' }),
|
|
3773
|
+
payload: Payload.create({
|
|
3774
|
+
items: [
|
|
3775
|
+
PayloadItem.create({
|
|
3776
|
+
name: 'post',
|
|
3777
|
+
required: true
|
|
3778
|
+
})
|
|
3779
|
+
]
|
|
3780
|
+
})
|
|
3781
|
+
})
|
|
3782
|
+
|
|
3783
|
+
// 4. Create controller and initialize system
|
|
3784
|
+
const controller = new Controller({
|
|
3785
|
+
system, // System implementation
|
|
3786
|
+
entities: [User, Post], // Entities
|
|
3787
|
+
relations: [UserPostRelation, PostLikeRelation], // Relations
|
|
3788
|
+
activities: [], // Activities
|
|
3789
|
+
interactions: [CreatePostInteraction, LikePostInteraction] // Interactions
|
|
3790
|
+
})
|
|
3791
|
+
|
|
3792
|
+
await controller.setup(true)
|
|
3793
|
+
|
|
3794
|
+
// 5. Use APIs
|
|
3795
|
+
// Create post
|
|
3796
|
+
const result = await controller.callInteraction('createPost', {
|
|
3797
|
+
user: { id: 'user1' },
|
|
3798
|
+
payload: {
|
|
3799
|
+
postData: {
|
|
3800
|
+
title: 'Hello World',
|
|
3801
|
+
content: 'This is my first post!'
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
})
|
|
3805
|
+
|
|
3806
|
+
// Like post
|
|
3807
|
+
await controller.callInteraction('likePost', {
|
|
3808
|
+
user: { id: 'user2' },
|
|
3809
|
+
payload: {
|
|
3810
|
+
post: { id: result.recordId }
|
|
3811
|
+
}
|
|
3812
|
+
})
|
|
3813
|
+
```
|
|
3814
|
+
|
|
3815
|
+
This API reference documentation covers all core APIs of the interaqt framework, providing complete parameter descriptions and practical usage examples. Developers can quickly get started and deeply use various framework features based on this documentation.
|