Entity CRUD — vyuh_server_plugin_entity_crud
EntityCrudPlugin is the standard descriptor-driven CRUD plugin. Features declare EntityCrudDescriptor<T> values; the plugin claims them during bootstrap, stores them in EntityCrudRegistry, and mounts the route surface during runtime.start().
The plugin talks through vyuh.db (DbAdapter), not Supabase-specific APIs. Use it with PostgresDbPlugin today and a QueryPlugin when you need ?query=-driven filtering/search/sort/group behavior or row scopes.
Install
dependencies:
vyuh_server_plugin_entity_crud:
hosted: https://pub.vyuh.tech
version: ^0.2.5
vyuh_entity_crud_types:
hosted: https://pub.vyuh.tech
version: ^0.2.2vyuh_entity_crud_types contains the shared envelopes and DTOs used by server responses and client entity readers.
Wiring
import 'package:vyuh_server/vyuh_server.dart';
import 'package:vyuh_server_plugin_postgres/vyuh_server_plugin_postgres.dart';
import 'package:cdx_query_server_postgres/cdx_query_server_postgres.dart';
import 'package:vyuh_server_plugin_entity_crud/vyuh_server_plugin_entity_crud.dart';
final runtime = await VyuhServer.bootstrap(
name: 'catalog-api',
plugins: [
EnvPlugin(
configs: [TypedEnvConfig.factory(PostgresConnectionConfig.new)],
),
PostgresDbPlugin(),
PostgresQueryPlugin(),
EntityCrudPlugin(),
],
features: [catalogFeature],
);PostgresQueryPlugin is a soft dependency. Unquery-driven CRUD can run without it, but any request with a ?query= filter/grouping expression or any descriptor whose config returns a scope filter needs a registered QueryPlugin.
Declaring an Entity
import 'package:cdx_query/cdx_query.dart';
final catalogFeature = FeatureDescriptor(
name: 'catalog',
descriptors: [
EntityCrudDescriptor<Product>(
name: 'Products',
basePath: '/products',
config: EntityCrudConfig<Product>(
schema: 'public',
table: 'products',
fromJson: Product.fromJson,
toJson: (p) => p.toJson(),
searchableFields: const ['name', 'description'],
sortableFields: const ['name', 'price_cents', 'created_at'],
defaultSortField: 'name',
capabilities: {
...kEntityCrudDefaults,
EntityCrudCapability.audit,
EntityCrudCapability.versioning,
},
scope: (req, _) {
final actor = req.actor;
return FilterCondition(
field: 'tenant_id',
operator: FilterOps.equals,
value: FilterValue.single(actor.tenantId),
);
},
),
customRoutes: (scope) {
scope.get('/featured', listFeaturedProducts);
},
),
],
);protectedPaths defaults to null, which protects the entire basePath subtree. Use protectedPaths: const [] only for a public entity, or pass relative paths to protect only selected endpoints.
Standard Routes
For a descriptor at /products, the always-mounted read and metadata surface is:
| Method | Path | Purpose |
|---|---|---|
GET | /products/ | List with canonical ?query=<cdx_query.Query JSON> |
GET | /products/count | Count matching rows |
GET | /products/grouped | Grouped list by ?query= with group_by cdx.query.group.filter_bucket |
GET | /products/:id/position | Position of a row in the current list window |
GET | /products/:id | Get one |
GET | /products/_/schema | Descriptor metadata for clients |
Mutating routes are controlled by the capability set:
| Capability | Routes |
|---|---|
EntityCrudCapability.create | POST /products/ |
EntityCrudCapability.update | PUT /products/:id |
EntityCrudCapability.delete | DELETE /products/:id |
kEntityCrudDefaults enables timestamps, saved views, create, update, and delete. Use kStandardCrud when you want only the three mutating verbs and will add lifecycle capabilities explicitly.
Lifecycle Capabilities
| Capability | Adds |
|---|---|
timestamps | Auto-stamps created_at and updated_at on writes |
audit | Stamps actor columns and mounts GET /:id/audit |
versioning | GET /:id/versions, GET /:id/versions/:n, GET /:id/compare/:from/:to, POST /:id/activate, POST /:id/deactivate, POST /:id/restore/:n |
drafts | ?drafts=true list/get/count/position modes plus GET/PUT/DELETE /:id/draft and POST /:id/draft/merge |
savedViews | Seven /saved-views routes for per-user saved filters, sort, and layout |
Drafts are stored in the shared entity_drafts table for the entity's schema. The regular list, get, count, and position routes switch to the draft partition when ?drafts=true.
Hooks and Validation
EntityCrudConfig<Product>(
schema: 'public',
table: 'products',
fromJson: Product.fromJson,
toJson: (p) => p.toJson(),
validateCreate: (payload) {
final name = (payload['name'] as String?)?.trim();
if (name == null || name.isEmpty) {
throw EntityValidationException(
field: 'name',
reason: 'Name is required',
);
}
return {...payload, 'name': name};
},
beforeCreate: (payload, req) async {
return {
...payload,
'slug': await generateUniqueSlug(payload['name'] as String),
};
},
afterCreate: (persisted, req) async {
vyuh.events.publish(ProductCreated(persisted['id'] as String));
},
);Create order is: authorization, parse body, companion extraction, createInterceptor, validateCreate, beforeCreate, timestamps/audit, insert, companion application, entity event, afterCreate.
Use createInterceptor when a POST should short-circuit into another workflow, such as approval-gated draft creation.
Constraints and Relationships
EntityCrudConfig<Product>(
...,
uniqueFields: const ['sku'],
uniqueComposites: const [
['tenant_id', 'name'],
],
inUseGuards: const [
FkInUseGuard(
schema: 'public',
table: 'order_items',
column: 'product_id',
label: 'order items',
),
],
relationships: const [
OneToManyRelationship(
path: '/order-items',
childSchema: 'public',
childTable: 'order_items',
childFk: 'product_id',
),
],
);Unique fields enable GET /check_uniqueness. Relationship descriptors mount /:id/<path> routes for one-to-many and many-to-many operations. FkInUseGuard blocks deletes that would orphan related records.
Custom Routes
customRoutes receives a RouterScope already mounted at basePath. Custom routes register before generated routes, so they can deliberately override a future framework route if needed.
EntityCrudDescriptor<Product>(
...,
customRoutes: (scope) {
scope.get('/featured', listFeaturedProducts);
scope.get('/:id/stock-status', stockStatus);
},
);