Version: 1.2 Date: December 25, 2025 Status: Draft
- Executive Summary
- Problem Statement
- Product Vision
- Goals and Objectives
- Target Users
- Core Concepts
- Functional Requirements
- Technical Architecture
- API Specification
- Frontend Requirements
- Data Model
- Implementation Status
- Non-Functional Requirements
- Future Considerations
MintPlayer.Spark is a low-code web application framework inspired by Vidyano. It enables developers to build data-driven web applications with minimal boilerplate code by using a unified PersistentObject model instead of traditional DTOs and eliminating the need for the Repository/Service/Controller pattern.
The framework consists of:
- A .NET backend library that provides generic CRUD operations via REST endpoints
- JSON-based model definitions stored in
App_Data/Model - An Angular frontend that dynamically renders UI based on available entity types
- RavenDB as the document database
Traditional web application development requires significant boilerplate code:
| Traditional Approach | Lines of Code per Entity |
|---|---|
| DTO classes | 20-50 |
| Repository interface | 10-20 |
| Repository implementation | 50-100 |
| Service interface | 10-20 |
| Service implementation | 50-100 |
| Controller | 50-100 |
| Total | 190-390 |
For an application with 20 entity types, this results in 3,800-7,800 lines of repetitive code.
Spark reduces this to near-zero by using a generic PersistentObject approach where entities are defined in JSON configuration files.
Build web applications in minutes, not months. Define your data model in JSON, and Spark handles the rest.
- Zero DTOs: Use
PersistentObjectas a universal data container - Zero Repositories: Generic middleware handles all CRUD operations
- Configuration over Code: Entity definitions live in JSON files
- Dynamic UI: Angular frontend adapts to available entity types automatically
- Type Safety: Strong typing through attribute metadata and CLR type mapping
| Goal | Success Metric |
|---|---|
| Reduce boilerplate code | 90% reduction in entity-related code |
| Accelerate development | New entity types added in < 5 minutes |
| Maintain flexibility | Support for custom business logic via hooks |
| Ensure type safety | Compile-time and runtime validation |
- Phase 1: Core framework with CRUD operations (Current)
- Phase 2: Model-driven persistence with JSON definitions
- Phase 3: Full Angular UI with dynamic entity management
- Phase 4: Validation rules and business logic hooks
- Phase 5: Reference handling between entities
| User Type | Description | Key Needs |
|---|---|---|
| Enterprise Developers | Building internal business applications | Rapid development, maintainability |
| Startups | Need to iterate quickly on MVPs | Speed, flexibility |
| Consultants | Delivering client projects | Reusability, customization |
-
As a developer, I want to add a new entity type by creating a JSON file, so that I don't have to write boilerplate code.
-
As a developer, I want the Angular UI to automatically show CRUD pages for my entities, so that I have immediate functionality.
-
As a developer, I want to define validation rules in the model, so that data integrity is enforced consistently.
-
As a developer, I want to define relationships between entities, so that I can build complex data models.
The PersistentObject is the fundamental data container that replaces traditional DTOs.
public class PersistentObject
{
public Guid Id { get; set; }
public string Name { get; set; }
public string ClrType { get; set; }
public PersistentObjectAttribute[] Attributes { get; set; }
}Each attribute represents a property of the entity with metadata.
public class PersistentObjectAttribute
{
public Guid Id { get; set; }
public string Name { get; set; }
public object? Value { get; set; }
public string DataType { get; set; } // string, number, decimal, boolean, datetime, guid, reference, AsDetail
public bool IsRequired { get; set; }
public string? Query { get; set; } // SparkQuery name for reference lookups in edit mode
public string? Breadcrumb { get; set; } // Computed display value for references/AsDetail (read-only, see Section 6.9)
public EShowedOn ShowedOn { get; set; } // Controls visibility on Query (list) and PersistentObject (detail) pages
public ValidationRule[] Rules { get; set; }
}Supported Data Types:
| DataType | Description | CLR Types |
|---|---|---|
string |
Text values | string |
number |
Integer values | int, long |
decimal |
Floating-point values | decimal, double, float |
boolean |
True/false values | bool |
datetime |
Date and time values | DateTime, DateTimeOffset |
guid |
Unique identifiers | Guid |
reference |
Foreign key to another entity (stored as string ID) | string with [Reference] attribute |
AsDetail |
Nested complex object displayed inline within the parent form | Class or record types (without [Reference] attribute) |
ShowedOn Enum:
The EShowedOn enum controls on which pages an attribute should be displayed:
[Flags]
public enum EShowedOn
{
Query = 1, // Attribute visible in query/list views
PersistentObject = 2 // Attribute visible in detail/edit views
}| Value | Description |
|---|---|
Query |
Attribute is shown as a column in query list pages (BsDatatableComponent) |
PersistentObject |
Attribute is shown in detail and edit forms |
Query | PersistentObject |
Attribute is shown in both list views and detail/edit pages (default behavior) |
Usage in JSON Model:
{
"name": "InternalCode",
"dataType": "string",
"showedOn": "PersistentObject"
}This allows developers to:
- Hide verbose/internal attributes from list views while showing them in detail pages
- Create lightweight list views with only essential columns
- Show computed/summary fields only in list views (e.g., index projections)
Data Type Detection During Synchronization:
The model synchronization process (--spark-synchronize-model) determines the dataType for each property based on the following rules:
| Property Type | Detection Rule | Resulting DataType |
|---|---|---|
string |
Simple type | string |
int, long, short, byte |
Simple numeric type | number |
decimal, double, float |
Floating-point type | decimal |
bool |
Boolean type | boolean |
DateTime, DateTimeOffset |
Date/time type | datetime |
Guid |
GUID type | guid |
string with [Reference] attribute |
Has [Reference(typeof(T))] attribute |
reference |
| Class or record type | Complex type without [Reference] |
AsDetail |
Entity types are defined in JSON files under App_Data/Model/:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Person",
"clrType": "Demo.Data.Person",
"displayFormat": "{FirstName} {LastName}",
"attributes": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "FirstName",
"dataType": "string",
"isRequired": true,
"showedOn": "Query, PersistentObject",
"rules": [
{ "type": "maxLength", "value": 100 }
]
},
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"name": "LastName",
"dataType": "string",
"isRequired": true,
"showedOn": "Query, PersistentObject"
},
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"name": "DateOfBirth",
"dataType": "datetime",
"isRequired": false,
"showedOn": "PersistentObject"
},
{
"id": "550e8400-e29b-41d4-a716-446655440004",
"name": "Company",
"dataType": "reference",
"referenceType": "Demo.Data.Company",
"isRequired": false,
"showedOn": "Query, PersistentObject"
}
]
}Not all PersistentObjects represent top-level database collections. The framework supports several categories:
| Category | Description | Example |
|---|---|---|
| Collection Entity | Maps directly to a RavenDB collection via IRavenQueryable<T> property on SparkContext |
Person, Company |
| AsDetail Object | Stored as a property within another document (dataType: AsDetail), not in its own collection. JSON model file is auto-generated during synchronization. Displayed inline within the parent form. |
Address on a Person document |
| Virtual | Completely unrelated to the database; used for UI-only data | Modal dialog data, toast notifications, wizard state |
Examples:
AsDetail object definition (auto-generated, can be used on multiple parent types like Person.Address or Company.Address):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Address",
"clrType": "Demo.Data.Address",
"displayFormat": "{Street}, {PostalCode} {City}",
"attributes": [
{ "id": "...", "name": "Street", "dataType": "string", "isVisible": true, "order": 1 },
{ "id": "...", "name": "PostalCode", "dataType": "string", "isVisible": true, "order": 2 },
{ "id": "...", "name": "City", "dataType": "string", "isVisible": true, "order": 3 },
{ "id": "...", "name": "State", "dataType": "string", "isVisible": true, "order": 4 }
]
}Parent entity with an AsDetail property:
{
"name": "Person",
"attributes": [
{ "name": "FirstName", "dataType": "string" },
{ "name": "LastName", "dataType": "string" },
{ "name": "Address", "dataType": "AsDetail" }
]
}Virtual object (for UI-only purposes):
{
"id": "770e8400-e29b-41d4-a716-446655440000",
"name": "ConfirmDeleteDialog",
"attributes": [
{ "name": "Title", "dataType": "string" },
{ "name": "Message", "dataType": "string" },
{ "name": "ConfirmButtonText", "dataType": "string" }
]
}While not a traditional EF DbContext, Spark uses a registry pattern to track available entity types:
public class SparkContext
{
public IRavenQueryable<Person> People { get; set; }
public IRavenQueryable<Company> Companies { get; set; }
// Add a property for each collection in your RavenDB database
}This pattern provides:
- Discoverability: The framework can reflect over the context to find all available collections
- Type Safety: Strong typing for each entity collection
- Familiar Pattern: Similar to EF Core's DbContext with DbSet properties
- Functional Queryables: Properties are injected with actual
IRavenQueryableinstances fromIDocumentSessionfor direct querying
A Spark Query defines how to retrieve a list of entities. It references a property on the SparkContext to determine which collection to query.
Definition (App_Data/Queries/{QueryName}.json):
{
"id": "880e8400-e29b-41d4-a716-446655440000",
"name": "GetPeople",
"contextProperty": "People",
"sortBy": "LastName",
"sortDirection": "asc"
}{
"id": "880e8400-e29b-41d4-a716-446655440001",
"name": "GetCompanies",
"contextProperty": "Companies",
"sortBy": "Name",
"sortDirection": "asc"
}Spark Queries are used:
- By Program Units to determine what data to show in list views
- By reference attributes (
Queryproperty) to populate lookup/autocomplete options
Program Units define the navigation structure of the application. They are configured in App_Data/programUnits.json.
Structure:
{
"programUnitGroups": [
{
"id": "990e8400-e29b-41d4-a716-446655440000",
"name": "Master Data",
"icon": "bi-database",
"order": 1,
"programUnits": [
{
"id": "990e8400-e29b-41d4-a716-446655440001",
"name": "People",
"icon": "bi-people",
"type": "query",
"queryId": "880e8400-e29b-41d4-a716-446655440000",
"order": 1
},
{
"id": "990e8400-e29b-41d4-a716-446655440002",
"name": "Companies",
"icon": "bi-building",
"type": "query",
"queryId": "880e8400-e29b-41d4-a716-446655440001",
"order": 2
}
]
},
{
"id": "990e8400-e29b-41d4-a716-446655440010",
"name": "Settings",
"icon": "bi-gear",
"order": 2,
"programUnits": [
{
"id": "990e8400-e29b-41d4-a716-446655440011",
"name": "Application Settings",
"icon": "bi-sliders",
"type": "persistentObject",
"persistentObjectId": "abc12345-...",
"order": 1
}
]
}
]
}Program Unit Types:
| Type | Description |
|---|---|
query |
References a Spark Query; navigates to a list view showing query results |
persistentObject |
References a specific PersistentObject by ID; navigates directly to that object's detail/edit view |
The Angular frontend reads this configuration to build the sidebar navigation with accordion groups.
Actions Classes provide a customization mechanism for entity-specific behavior. They follow an inheritance chain that allows overriding default CRUD logic.
Inheritance Chain:
MintPlayer.Spark.DefaultPersistentObjectActions (library base)
└── DemoApp.DefaultPersistentObjectActions (application default)
└── DemoApp.Actions.PersonActions (entity-specific)
└── DemoApp.Actions.CompanyActions (entity-specific)
Library Base Class (MintPlayer.Spark/Actions/DefaultPersistentObjectActions.cs):
public class DefaultPersistentObjectActions<T> where T : class
{
protected IDocumentSession Session { get; }
public virtual async Task<IEnumerable<T>> OnQuery()
=> await Session.Query<T>().ToListAsync();
public virtual async Task<T?> OnLoad(string id)
=> await Session.LoadAsync<T>(id);
public virtual async Task<T> OnSave(T entity)
{
await Session.StoreAsync(entity);
await Session.SaveChangesAsync();
return entity;
}
public virtual async Task OnDelete(string id)
{
Session.Delete(id);
await Session.SaveChangesAsync();
}
public virtual Task OnBeforeSave(T entity) => Task.CompletedTask;
public virtual Task OnAfterSave(T entity) => Task.CompletedTask;
public virtual Task OnBeforeDelete(T entity) => Task.CompletedTask;
}Application Default (DemoApp/Actions/DefaultPersistentObjectActions.cs):
public class DefaultPersistentObjectActions<T> : Spark.DefaultPersistentObjectActions<T>
where T : class
{
// Application-wide customizations (logging, auditing, etc.)
}Entity-Specific Actions (DemoApp/Actions/PersonActions.cs):
public class PersonActions : DefaultPersistentObjectActions<Person>
{
public override async Task OnBeforeSave(Person entity)
{
// Custom validation or business logic for Person
if (string.IsNullOrEmpty(entity.Email))
throw new ValidationException("Email is required");
}
}Registration:
Actions classes are discovered via naming convention ({TypeName}Actions) or explicit registration.
- Description: System shall synchronize entity definitions between
SparkContextandApp_Data/Model/*.jsonwhen the application is started with the--spark-synchronize-modelcommand-line parameter in Development mode - Priority: High
- Status: Implemented
Synchronization Process:
- Reflect over
SparkContextproperties to find allIRavenQueryable<T>collections - For each entity type
T, generate or update the corresponding JSON model file. Do not remove any attributes. Only add/update attributes. - Property Type Detection: For each property on an entity, the synchronizer determines the
dataType:- Simple types (
string,int,bool,DateTime, etc.): Mapped directly to corresponding dataType (see Data Type Detection table above) - Reference properties (properties with
[Reference]attribute): SetsdataTypeto"Reference"and populates theQueryproperty for lookups - AsDetail properties (class/record types without
[Reference]): SetsdataTypeto"AsDetail", queues the type for processing, and generates a separate JSON model file
- Simple types (
- AsDetail Type Discovery: For each AsDetail property:
- Queues the AsDetail type for processing
- Generates a separate JSON model file for the AsDetail type (e.g.,
Address.json) - Recursively discovers nested AsDetail types within AsDetail types
- JSON files remain static during normal runtime (no runtime discovery)
- Developers explicitly trigger synchronization during development
Reference vs AsDetail Detection:
| Scenario | Detection Rule | DataType |
|---|---|---|
string property with [Reference(typeof(Company))] |
Has [Reference] attribute |
Reference |
Address property (class type, no [Reference]) |
Class/record type without [Reference] |
AsDetail |
AsDetail Type Requirements: A type is considered an AsDetail type if:
- It is a class or record (not a value type, enum, or primitive)
- It is not
string - It does NOT have a
[Reference]attribute on the property
- Description: System shall expose REST endpoints for Create, Read, Update, Delete operations
- Priority: High
- Status: Implemented
| Endpoint | Method | Description |
|---|---|---|
/spark/po/{type} |
GET | List all objects of type |
/spark/po/{type}/{id} |
GET | Get single object by ID |
/spark/po/{type} |
POST | Create new object |
/spark/po/{type}/{id} |
PUT | Update existing object |
/spark/po/{type}/{id} |
DELETE | Delete object |
- Description: System shall provide an API to query available entity types
- Priority: High
- Status: Implemented
| Endpoint | Method | Description |
|---|---|---|
/spark/types |
GET | List all registered entity types |
/spark/types/{id} |
GET | Get entity type definition by ID |
- Description: System shall provide extension methods for bidirectional mapping between entities and PersistentObjects
- Priority: High
- Status: Partial (EntityMapper service implemented, extension method helpers not yet added)
// Map entity properties to PersistentObject attributes
public static void PopulateAttributeValues<T>(this PersistentObject po, T entity);
// Map PersistentObject attributes to entity properties
public static void PopulateObjectValues<T>(this PersistentObject po, T entity);- Description: System shall validate PersistentObject data against model rules before persistence
- Priority: Medium
- Status: Implemented
Supported validation rules:
required: Field must have a valuemaxLength: Maximum string lengthminLength: Minimum string lengthrange: Numeric value rangeregex: Pattern matchingemail: Email format validation
- Description: System shall support references between entity types
- Priority: Medium
- Status: Partial (breadcrumb resolution works,
[Reference]attribute not yet implemented)
RavenDB Reference Constraint:
Reference properties on entity classes must be of type string (RavenDB restriction). The [Reference] attribute indicates the expected target type:
public class Person
{
public Guid Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
[Reference(typeof(Company))]
public string Company { get; set; } // Stores the RavenDB document ID
}Reference Behavior:
- The middleware shall call RavenDB's
.Include<T>()method to eager-load referenced documents - The
Breadcrumbproperty on the attribute contains the computed display value (resolved on backend) - In edit mode, the
Queryproperty specifies which SparkQuery to use for lookup/autocomplete - Frontend does not need to know the reference target type; it only displays Breadcrumb and uses Query for selection
- Description: System shall support Spark Query definitions that specify which SparkContext property to use for retrieving entity lists
- Priority: High
- Status: Implemented
Query Definition Location: App_Data/Queries/*.json
Endpoints:
| Endpoint | Method | Description |
|---|---|---|
/spark/queries |
GET | List all registered Spark Queries |
/spark/queries/{id} |
GET | Get query definition by ID |
/spark/queries/{id}/execute |
GET | Execute query and return results as PersistentObjects |
- Description: System shall support Program Unit configuration for defining application navigation structure
- Priority: High
- Status: Implemented
Configuration Location: App_Data/programUnits.json
Endpoints:
| Endpoint | Method | Description |
|---|---|---|
/spark/program-units |
GET | Return the full program units configuration (groups + units) |
- Description: System shall support customizable Actions classes for entity-specific business logic
- Priority: Medium
- Status: Implemented
Discovery Mechanism:
- Look for
{TypeName}Actionsclass in any loaded assembly (e.g.,PersonActionsforPersonentity) - Fall back to application's registered
IPersistentObjectActions<T>in DI container - Fall back to library's
DefaultPersistentObjectActions<T>
Fallback Behavior (Required):
- When a CRUD operation is performed on an entity type that has no custom Actions class registered, the system MUST automatically use
DefaultPersistentObjectActions<T>to handle the operation - This ensures all entity types work out-of-the-box without requiring explicit Actions registration
- The default implementation provides standard RavenDB CRUD operations with no custom business logic
Lifecycle Hooks:
OnQueryAsync(session)- Called when listing entitiesOnLoadAsync(session, id)- Called when loading a single entityOnSaveAsync(session, entity)- Called when creating/updating (calls OnBeforeSaveAsync/OnAfterSaveAsync)OnDeleteAsync(session, id)- Called when deleting (calls OnBeforeDeleteAsync)OnBeforeSaveAsync(entity)- Hook before save (validation, transformation)OnAfterSaveAsync(entity)- Hook after save (notifications, auditing)OnBeforeDeleteAsync(entity)- Hook before delete (cleanup, cascade)
Registration:
// Register entity-specific Actions in Program.cs
builder.Services.AddSparkActions<PersonActions, Person>();- Description: System shall support the
[FromIndex]attribute on projection classes to route list queries through RavenDB indexes instead of collections - Priority: Medium
- Status: Implemented
Requirements:
- Define
FromIndexAttributeinMintPlayer.Spark.Abstractions - At application startup, call
CreateSparkIndexes()to:- Deploy all indexes from the application assembly
- Register indexes in the
IndexRegistry(derives collection type fromAbstractIndexCreationTask<T>generic parameter) - Register projections with
[FromIndex]attribute linking them to their indexes
- When executing a Spark Query for an entity type that has a registered projection:
- Query the corresponding RavenDB index instead of the collection
- Return results as PersistentObjects using the projection type for attribute mapping
- When no projection is registered, continue querying the collection directly
- Detail/Edit views always use the full collection entity, not the projection type
Architecture Benefits:
- Entity classes can live in a separate library project (e.g.,
DemoApp.Library) - Projection classes remain in the application project with
[FromIndex]attribute - No circular dependencies between library and application
- Collection type is derived from
AbstractIndexCreationTask<T>generic parameter
Model Synchronization Impact:
When --spark-synchronize-model runs and an entity has a registered projection:
- Do NOT create a separate JSON model file for the projection type
- Merge both classes (
CarandVCar) into a single persistent-object JSON file (Car.json) - Attributes from both classes are merged by
name:- If a property exists in both classes, use the merged definition
- If the property-type on the collection-type is not convertible to the property-type on the index property, throw an error. Example: string <> int
- Store the projection type CLR name in the model:
"queryType": "Demo.Data.VCar" - Store the index name from the registry in the model:
"indexName": "Cars_Overview"
Example Merged Model (Car.json):
{
"id": "...",
"name": "Car",
"clrType": "DemoApp.Library.Car",
"queryType": "DemoApp.Data.VCar",
"indexName": "Cars_Overview",
"attributes": [
{ "name": "Id", "dataType": "guid" },
{ "name": "Brand", "dataType": "string" },
{ "name": "Model", "dataType": "string" },
{ "name": "Year", "dataType": "number" },
{ "name": "FullName", "dataType": "string", "inCollectionType": false },
{ "name": "Color", "dataType": "string", "inQueryType": false },
{ "name": "Price", "dataType": "decimal", "inQueryType": false }
]
}In this example:
FullNameexists only inVCar(computed by the index) →inCollectionType: falseColorandPriceexist only inCar(not projected) →inQueryType: falseId,Brand,Model,Yearexist in both → no flags needed
When displaying Reference or AsDetail attributes in the Angular frontend, the framework needs to render a human-readable representation of the referenced/nested object. This is controlled by the displayFormat property on the entity type definition.
Display Properties:
| Property | Description | Example |
|---|---|---|
displayFormat |
Template string with {PropertyName} placeholders for building a formatted display value |
"{Street}, {PostalCode} {City}" |
displayAttribute |
(Fallback) Single attribute name to use as display value | "Street" |
Resolution Order:
The framework resolves the display value in this order:
- If
displayFormatis specified, use template substitution (e.g.,"{Street}, {PostalCode} {City}"→"Deinzestraat, 9800 Deinze") - If
displayAttributeis specified, use that single attribute's value - Fall back to common property names:
Name,Title,name,title
Example Model Configuration:
{
"name": "Address",
"clrType": "DemoApp.Data.Address",
"displayFormat": "{Street}, {PostalCode} {City}",
"attributes": [
{ "name": "Street", "dataType": "string" },
{ "name": "PostalCode", "dataType": "string" },
{ "name": "City", "dataType": "string" },
{ "name": "State", "dataType": "string" }
]
}Rendering Behavior in Angular:
| Context | Where Displayed | Value Used |
|---|---|---|
| Parent form (read-only input) | Person edit page, Address field | displayFormat or displayAttribute from Address.json |
| Reference dropdown options | Company selection dropdown | displayFormat or displayAttribute from Company.json |
| Detail page header | Person detail page <h2> |
displayFormat or displayAttribute from Person.json |
| List/table rows | Query results in BsDatatableComponent | displayFormat or displayAttribute for the breadcrumb column |
Backend Breadcrumb Resolution:
For Reference attributes, the backend resolves the breadcrumb property on the PersistentObjectAttribute by:
- Loading the referenced document using RavenDB's
.Include<T>()method - Applying the target entity's
displayFormat(ordisplayAttribute) to generate the breadcrumb - Returning the computed
breadcrumbvalue in the API response
This allows the frontend to display "{Company}" as "Acme Corporation" without needing to know the reference target type or make additional API calls.
RavenDB indexes allow projecting entity data into optimized query views. Spark supports custom indexes using RavenDB's AbstractIndexCreationTask combined with the [FromIndex] attribute on projection classes to automatically route list queries through indexes.
Overview:
| Concept | Description |
|---|---|
| Collection Entity | The class stored in RavenDB (e.g., Car, Person) - can live in a library project |
| Projection Type | The class returned by a RavenDB index (e.g., VCar, VPerson) - lives in app project |
[FromIndex] Attribute |
Applied to the projection class to link it to its index |
IndexRegistry |
Service that tracks index → collection type → projection type relationships |
How It Works:
- Developer creates a RavenDB index that projects the collection entity to a view model
- Developer applies
[FromIndex(typeof(Cars_Overview))]to the projection class - At application startup,
CreateSparkIndexes()deploys indexes and populatesIndexRegistry - The registry derives collection type from
AbstractIndexCreationTask<T>generic parameter - When Angular requests entities, Spark queries the RavenDB index via the registry lookup
- The index projection type (
VCar) is used for list views, while the full entity (Car) is used for detail/edit views
Entity Class (in library project - no attributes needed):
// DemoApp.Library/Car.cs
namespace DemoApp.Library;
public class Car
{
public Guid Id { get; set; }
public string Brand { get; set; }
public string Model { get; set; }
public int Year { get; set; }
public string Color { get; set; }
public decimal Price { get; set; }
// ... additional properties not needed in list views
}Index Definition (in app project):
// DemoApp/Indexes/Cars_Overview.cs
using DemoApp.Library;
public class Cars_Overview : AbstractIndexCreationTask<Car> // Generic param defines collection type
{
public Cars_Overview()
{
Map = cars => from car in cars
select new VCar
{
Id = car.Id,
Brand = car.Brand,
Model = car.Model,
FullName = car.Brand + " " + car.Model,
Year = car.Year
};
}
}Projection Class with FromIndex Attribute (in app project):
// DemoApp/Data/VCar.cs
using DemoApp.Indexes;
using MintPlayer.Spark.Abstractions;
[FromIndex(typeof(Cars_Overview))] // Links projection to its index
public class VCar
{
public Guid Id { get; set; }
public string Brand { get; set; }
public string Model { get; set; }
public string FullName { get; set; } // Computed: "Toyota Camry"
public int Year { get; set; }
// Subset of properties optimized for list display
}FromIndexAttribute Properties:
| Property | Type | Required | Description |
|---|---|---|---|
IndexType |
Type |
Yes | The RavenDB index type that produces this projection (constructor parameter) |
FromIndex Attribute Behavior:
| Scenario | Query Target | Type Used |
|---|---|---|
| Entity has registered projection | RavenDB Index | Projection type from registry |
| Entity has no registered projection | RavenDB Collection | Collection entity type |
Startup Index Creation:
Spark automatically creates/updates RavenDB indexes at application startup:
// Called internally by Spark during startup
await IndexCreation.CreateIndexesAsync(
documentStore,
typeof(SparkContext).Assembly // Scans for AbstractIndexCreationTask implementations
);Benefits:
- Performance: Indexes are pre-computed, making list queries faster
- Reduced Payload: Projection types contain only fields needed for list views
- Computed Fields: Indexes can compute/combine fields (e.g.,
FullNamefromBrand+Model) - Separation of Concerns: List view shape (
VCar) differs from storage shape (Car)
- Description: Application shall use
@mintplayer/ng-bootstrapBsShellComponent - Priority: High
- Status: Implemented
- Description: Sidebar shall display navigation based on Program Units configuration
- Priority: High
- Status: Implemented
Requirements:
- Fetch program units from
/spark/program-unitson initialization - Display Program Unit Groups as accordion sections
- Each group contains Program Units as navigation links
- Program Unit with
type: "query"→ navigates to list page for that query - Program Unit with
type: "persistentObject"→ navigates directly to that object's detail view - Display icons from the configuration (Bootstrap Icons)
- Description: Display paginated list of entities for a given type using
BsDatatableComponentfrom@mintplayer/ng-bootstrap - Priority: High
- Status: Implemented
Features:
- Use
BsDatatableComponentfor the data grid - Columns generated dynamically based on entity attributes
- Built-in search/filter functionality
- Sorting by column
- Pagination
- "New" button to create entity
- Row click navigates to detail/edit page
- Description: Display single entity with all attributes
- Priority: High
- Status: Implemented
Features:
- Read-only view of entity
- Edit button to switch to edit mode
- Delete button with confirmation
- Back navigation
- Description: Form to create new entity
- Priority: High
- Status: Implemented
Features:
- Dynamic form generation based on entity type definition
- Appropriate input controls per data type
- Validation based on attribute rules
- Reference field with lookup/search
- Save and Cancel buttons
- Description: Form to edit existing entity
- Priority: High
- Status: Implemented
Features:
- Pre-populated form with current values
- Same functionality as create page
- Update confirmation
┌─────────────────────────────────────────────────────────────┐
│ Angular Frontend │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Shell │ │ List │ │ Detail │ │ Create │ │
│ │Component │ │ Page │ │ Page │ │ Page │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ SparkService │
└─────────────────────────┼───────────────────────────────────┘
│ HTTP/REST
┌─────────────────────────┼───────────────────────────────────┐
│ ASP.NET Core │
│ ┌──────────────────────┴───────────────────────────────┐ │
│ │ SparkMiddleware │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌──────────────────────┴───────────────────────────────┐ │
│ │ Endpoint Handlers │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ List │ │ Get │ │ Create │ │ Update │ Delete │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌──────────────────────┴───────────────────────────────┐ │
│ │ IDatabaseAccess │ │
│ │ (RavenDB Implementation) │ │
│ └──────────────────────┬───────────────────────────────┘ │
└─────────────────────────┼───────────────────────────────────┘
│
┌─────────────────────────┼───────────────────────────────────┐
│ RavenDB │
│ ┌──────────────────────┴───────────────────────────────┐ │
│ │ Document Collections │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
| Layer | Technology | Version |
|---|---|---|
| Frontend | Angular | 21.0.0 |
| UI Components | @mintplayer/ng-bootstrap | Latest |
| Backend | ASP.NET Core | .NET 10.0 |
| Database | RavenDB | 6.2.6 |
| DI Generation | MintPlayer.SourceGenerators | 5.2.2 |
MintPlayer.Spark/
├── MintPlayer.Spark.Abstractions/
│ ├── PersistentObject.cs
│ ├── PersistentObjectAttribute.cs
│ ├── IDatabaseAccess.cs
│ └── ISparkContext.cs
│
├── MintPlayer.Spark/
│ ├── Configuration/
│ │ └── SparkOptions.cs
│ ├── Endpoints/
│ │ └── PersistentObject/
│ │ ├── List.cs
│ │ ├── Get.cs
│ │ ├── Create.cs
│ │ ├── Update.cs
│ │ └── Delete.cs
│ ├── Services/
│ │ ├── DatabaseAccess.cs
│ │ ├── ModelLoader.cs
│ │ └── SparkContext.cs
│ ├── Extensions/
│ │ └── PersistentObjectExtensions.cs
│ └── SparkMiddleware.cs
│
└── Demo/DemoApp/
├── App_Data/
│ └── Model/
│ ├── Person.json
│ └── Company.json
├── Data/
│ ├── Person.cs
│ └── Company.cs
├── ClientApp/
│ └── src/
│ ├── app/
│ │ ├── core/
│ │ │ ├── services/
│ │ │ │ └── spark.service.ts
│ │ │ └── models/
│ │ │ ├── persistent-object.ts
│ │ │ └── entity-type.ts
│ │ ├── pages/
│ │ │ ├── entity-list/
│ │ │ ├── entity-detail/
│ │ │ ├── entity-create/
│ │ │ └── entity-edit/
│ │ ├── app.ts
│ │ └── app.routes.ts
│ └── ...
└── Program.cs
The framework uses MintPlayer.SourceGenerators for compile-time dependency injection registration. This eliminates manual service registration boilerplate.
Registering Services:
Use the [Register] attribute to automatically register services with the DI container:
[Register(ServiceLifetime.Scoped)]
internal class DatabaseAccess : IDatabaseAccess
{
// Implementation
}Injecting Dependencies:
Use the [Inject] attribute on partial classes to generate constructor injection:
[Inject]
internal partial class SparkMiddleware
{
private readonly IDatabaseAccess databaseAccess;
private readonly ISparkContext sparkContext;
}The source generator creates the constructor automatically:
// Generated code
internal partial class SparkMiddleware
{
public SparkMiddleware(IDatabaseAccess databaseAccess, ISparkContext sparkContext)
{
this.databaseAccess = databaseAccess;
this.sparkContext = sparkContext;
}
}Benefits:
- No manual
services.AddScoped<>()calls needed - Compile-time validation of dependencies
- Reduced boilerplate code
- Consistent registration patterns across the framework and applications
NuGet Packages:
MintPlayer.SourceGenerators- The source generatorMintPlayer.SourceGenerators.Attributes- Contains[Register]and[Inject]attributes
Returns all registered entity types.
Response:
{
"entityTypes": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Person",
"clrType": "Demo.Data.Person",
"attributes": [...]
}
]
}Returns a single entity type definition.
Parameters:
id(path, Guid): Entity type ID
Response:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Person",
"clrType": "Demo.Data.Person",
"attributes": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "FirstName",
"dataType": "string",
"isRequired": true,
"rules": []
}
]
}List all objects of a given type.
Parameters:
type(path, string): CLR type name or entity type GUID
Response:
[
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "John Doe",
"clrType": "Demo.Data.Person",
"attributes": [
{ "id": "...", "name": "FirstName", "value": "John" },
{ "id": "...", "name": "LastName", "value": "Doe" }
]
}
]Get a single object by ID.
Parameters:
type(path, string): CLR type name or entity type GUIDid(path, Guid): Object ID
Response:
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "John Doe",
"clrType": "Demo.Data.Person",
"attributes": [...]
}Create a new object.
Parameters:
type(path, string): CLR type name or entity type GUID
Request Body:
{
"attributes": [
{ "name": "FirstName", "value": "John" },
{ "name": "LastName", "value": "Doe" }
]
}Response: 201 Created with created object
Update an existing object.
Parameters:
type(path, string): CLR type name or entity type GUIDid(path, Guid): Object ID
Request Body:
{
"attributes": [
{ "name": "FirstName", "value": "Jane" },
{ "name": "LastName", "value": "Doe" }
]
}Response: 200 OK with updated object
Delete an object.
Parameters:
type(path, string): CLR type name or entity type GUIDid(path, Guid): Object ID
Response: 204 No Content
export const routes: Routes = [
{
path: '',
component: ShellComponent,
children: [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', loadComponent: () => import('./pages/home/home.component') },
{ path: 'entity/:typeId', loadComponent: () => import('./pages/entity-list/entity-list.component') },
{ path: 'entity/:typeId/new', loadComponent: () => import('./pages/entity-create/entity-create.component') },
{ path: 'entity/:typeId/:id', loadComponent: () => import('./pages/entity-detail/entity-detail.component') },
{ path: 'entity/:typeId/:id/edit', loadComponent: () => import('./pages/entity-edit/entity-edit.component') }
]
}
];@Injectable({ providedIn: 'root' })
export class SparkService {
private baseUrl = '/spark';
// Entity Types
getEntityTypes(): Observable<EntityType[]>;
getEntityType(id: string): Observable<EntityType>;
// PersistentObjects
list(typeId: string): Observable<PersistentObject[]>;
get(typeId: string, id: string): Observable<PersistentObject>;
create(typeId: string, data: Partial<PersistentObject>): Observable<PersistentObject>;
update(typeId: string, id: string, data: Partial<PersistentObject>): Observable<PersistentObject>;
delete(typeId: string, id: string): Observable<void>;
}@Component({
selector: 'app-shell',
template: `
<bs-shell
[navItems]="navItems$ | async"
[sidebarMode]="'accordion'">
<router-outlet />
</bs-shell>
`
})
export class ShellComponent {
navItems$ = this.sparkService.getEntityTypes().pipe(
map(types => types.map(t => ({
title: t.name,
icon: 'fa-database',
routerLink: ['/entity', t.id]
})))
);
}The create/edit pages shall dynamically generate form controls based on attribute definitions:
| Data Type | Angular Control | Notes |
|---|---|---|
| string | <input type="text"> |
|
| number | <input type="number"> |
|
| decimal | <input type="number" step="0.01"> |
|
| boolean | <input type="checkbox"> |
|
| datetime | <input type="datetime-local"> |
|
| AsDetail | <bs-input-group><input type="text" readonly><button [color]="colors.secondary"><i class="bi bi-pencil"></i></button></bs-input-group> |
Readonly input displays the displayFormat value. Button opens a BsModal component showing the nested object in a po-edit-form. |
| Reference | <bs-input-group><input type="text" readonly><button [color]="colors.secondary">...</button></bs-input-group> |
Readonly input displays the breadcrumb value. Button opens a BsModal containing a BsDatatableComponent showing items from the attribute's Query. User selects a row to set the reference value. |
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Person",
"clrType": "Demo.Data.Person",
"displayFormat": "{FirstName} {LastName}",
"attributes": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"name": "FirstName",
"label": "First Name",
"dataType": "string",
"isRequired": true,
"isVisible": true,
"isReadOnly": false,
"order": 1,
"rules": [
{ "type": "maxLength", "value": 100 }
]
},
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"name": "LastName",
"label": "Last Name",
"dataType": "string",
"isRequired": true,
"isVisible": true,
"isReadOnly": false,
"order": 2,
"rules": [
{ "type": "maxLength", "value": 100 }
]
},
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"name": "Email",
"label": "Email Address",
"dataType": "string",
"isRequired": true,
"isVisible": true,
"isReadOnly": false,
"order": 3,
"rules": [
{ "type": "email" },
{ "type": "maxLength", "value": 255 }
]
},
{
"id": "550e8400-e29b-41d4-a716-446655440004",
"name": "DateOfBirth",
"label": "Date of Birth",
"dataType": "datetime",
"isRequired": false,
"isVisible": true,
"isReadOnly": false,
"order": 4
},
{
"id": "550e8400-e29b-41d4-a716-446655440005",
"name": "Company",
"label": "Company",
"dataType": "reference",
"query": "GetCompanies",
"isRequired": false,
"isVisible": true,
"isReadOnly": false,
"order": 5
}
]
}{
"id": "660e8400-e29b-41d4-a716-446655440000",
"name": "Company",
"clrType": "Demo.Data.Company",
"displayAttribute": "Name",
"attributes": [
{
"id": "660e8400-e29b-41d4-a716-446655440001",
"name": "Name",
"label": "Company Name",
"dataType": "string",
"isRequired": true,
"isVisible": true,
"isReadOnly": false,
"order": 1,
"rules": [
{ "type": "maxLength", "value": 200 }
]
},
{
"id": "660e8400-e29b-41d4-a716-446655440002",
"name": "Website",
"label": "Website",
"dataType": "string",
"isRequired": false,
"isVisible": true,
"isReadOnly": false,
"order": 2,
"rules": [
{ "type": "url" }
]
},
{
"id": "660e8400-e29b-41d4-a716-446655440003",
"name": "EmployeeCount",
"label": "Number of Employees",
"dataType": "number",
"isRequired": false,
"isVisible": true,
"isReadOnly": false,
"order": 3,
"rules": [
{ "type": "range", "min": 1, "max": 1000000 }
]
}
]
}namespace Demo.Data;
public class Person
{
public Guid Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime? DateOfBirth { get; set; }
public Guid? CompanyId { get; set; }
// Computed property for display
public string FullName => $"{FirstName} {LastName}";
}namespace Demo.Data;
public class Company
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Website { get; set; }
public int? EmployeeCount { get; set; }
}| Component | Status | Notes |
|---|---|---|
| Solution Structure | Complete | 4 projects configured |
| PersistentObject Model | Complete | Full implementation with metadata |
| PersistentObjectAttribute | Complete | Includes rules, dataType, all metadata |
| IDatabaseAccess | Complete | RavenDB implementation |
| CRUD Endpoints | Complete | All 5 operations (FR-BE-002) |
| SparkMiddleware | Complete | Pre/post processing |
| AddSpark/UseSpark/MapSpark | Complete | Extension methods |
| Model Synchronization | Complete | FR-BE-001, includes embedded type discovery |
| Entity Type Registry | Complete | FR-BE-003, /spark/types endpoints |
| Spark Queries | Complete | FR-BE-007, query execution with sorting |
| Program Units | Complete | FR-BE-008, hierarchical navigation |
| Validation | Complete | FR-BE-005, all rules implemented |
| Entity Mapper | Complete | Bidirectional Entity ↔ PersistentObject mapping |
| PopulateAttributeValues | Not Started | FR-BE-004, extension method helpers |
| PopulateObjectValues | Not Started | FR-BE-004, extension method helpers |
| Reference Handling | Partial | FR-BE-006, breadcrumb resolution works, [Reference] attribute not implemented |
| Actions Classes | Complete | FR-BE-009, lifecycle hooks |
| FromIndex Attribute | Complete | FR-BE-010, [FromIndex] attribute and index-based queries |
| Angular Shell | Complete | FR-FE-001, BsShellComponent integration |
| Dynamic Sidebar | Complete | FR-FE-002, program units menu |
| Entity List Page | Complete | FR-FE-003, BsDatatableComponent with pagination/sorting |
| Entity Detail Page | Complete | FR-FE-004, read-only view with edit/delete |
| Entity Create Page | Complete | FR-FE-005, dynamic form generation |
| Entity Edit Page | Complete | FR-FE-006, pre-populated forms |
| Validation Error Display | Partial | Backend returns errors, frontend needs display |
| AsDetail Objects UI | Partial | Backend needs AsDetail type detection, frontend needs nested form UI |
| displayFormat Support | Not Started | Template-based display values (e.g., "{Street}, {PostalCode} {City}") |
| ShowedOn Support | Complete | EShowedOn enum to control attribute visibility on Query vs PersistentObject pages |
| Search/Filter | Not Started | List view filtering capability |
Phase 1: Core Framework ✅ COMPLETE
├── [x] Solution structure
├── [x] PersistentObject abstractions
├── [x] RavenDB integration
├── [x] CRUD endpoints
└── [x] Middleware setup
Phase 2: Model-Driven Persistence ✅ COMPLETE
├── [x] Enhanced PersistentObjectAttribute with metadata
├── [x] JSON model file synchronization (App_Data/Model)
├── [x] AsDetail type discovery and generation
├── [x] SparkContext implementation
├── [x] Entity type API endpoints (/spark/types)
├── [x] Spark Queries (/spark/queries)
├── [x] Program Units (/spark/program-units)
├── [x] Entity Mapper (bidirectional mapping)
├── [ ] PopulateAttributeValues extension (helper methods)
└── [ ] PopulateObjectValues extension (helper methods)
Phase 3: Validation & Rules ✅ COMPLETE
├── [x] Validation rule definitions (required, maxLength, minLength, range, regex, email, url)
├── [x] Server-side validation
├── [x] Validation error responses (400 BadRequest)
└── [ ] Frontend validation error display
Phase 4: Angular Frontend ✅ COMPLETE
├── [x] @mintplayer/ng-bootstrap integration (BsShellComponent, BsDatatableComponent)
├── [x] SparkService implementation (all HTTP methods)
├── [x] Shell component with sidebar (program units menu)
├── [x] Entity list page (QueryList with pagination/sorting)
├── [x] Entity detail page (PoDetail with edit/delete)
├── [x] Entity create page (PoCreate with dynamic forms)
├── [x] Entity edit page (PoEdit with pre-populated forms)
├── [x] Dynamic form generation (text, number, checkbox, datetime, select)
└── [x] Reference field dropdowns (query execution for lookups)
Phase 5: Advanced Features (Current)
├── [ ] Search and filtering in list views
├── [x] Sorting and pagination
├── [x] Reference field lookups
├── [ ] AsDetail object editing UI
├── [ ] Validation error display in frontend
├── [x] Actions classes (FR-BE-009) - lifecycle hooks
├── [ ] Reference attribute ([Reference]) support
├── [x] FromIndex attribute for index-based queries (FR-BE-010)
├── [ ] displayFormat support (template-based breadcrumbs, see Section 6.9)
├── [x] ShowedOn support (EShowedOn enum for Query/PersistentObject visibility)
└── [ ] Computed/derived attributes
| Requirement | Target |
|---|---|
| API response time (list) | < 200ms for 1000 records |
| API response time (single) | < 50ms |
| Page load time | < 2 seconds |
| Concurrent users | 100+ |
- All API endpoints require authentication (configurable)
- Input validation on all user-provided data
- Protection against common attacks (XSS, CSRF, injection)
- Secure RavenDB connection (TLS)
- Stateless API design for horizontal scaling
- RavenDB clustering support
- CDN-friendly static assets
- Clean separation of concerns
- Comprehensive logging
- Configuration via appsettings.json
- Docker support
| Platform | Requirement |
|---|---|
| .NET Runtime | 10.0+ |
| Node.js | 20+ |
| Browsers | Chrome, Firefox, Safari, Edge (latest 2 versions) |
| RavenDB | 6.0+ |
- Query Builder: Visual query builder for complex filters
- Bulk Operations: Import/export CSV/Excel
- Audit Trail: Track changes to entities
- Versioning: Entity version history
- Workflows: State machine for entity lifecycle
- Notifications: Real-time updates via SignalR
- Multi-tenancy: Tenant isolation
- Localization: Multi-language support for labels
- Custom Views: Saved list configurations
- Dashboard: Charts and metrics
- Authentication: Support for OAuth2/OIDC, Azure AD
- Storage: File attachment support (Azure Blob, S3)
- Email: Notification templates
- Export: PDF generation for reports
| Term | Definition |
|---|---|
| PersistentObject | Universal data container replacing traditional DTOs |
| Attribute | A named property with value and metadata |
| Entity Type | A definition of a data structure (stored as JSON) |
| ClrType | The .NET type name used for entity identification |
| Reference | A relationship between two entity types |
| displayFormat | Template string with {PropertyName} placeholders for rendering human-readable display values |
| Breadcrumb | Computed display value for Reference/AsDetail attributes, resolved using displayFormat |
| FromIndex | Attribute applied to projection classes to link them to their RavenDB index for index-based list queries |
| Projection Type | A view model class returned by a RavenDB index, typically a subset/transformation of the collection entity |
| ShowedOn | Flag enum (EShowedOn) controlling attribute visibility on Query (list) and PersistentObject (detail/edit) pages |
This document is a living specification and will be updated as the project evolves.