Skip to content

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

yaml
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.2

vyuh_entity_crud_types contains the shared envelopes and DTOs used by server responses and client entity readers.

Wiring

dart
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

dart
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:

MethodPathPurpose
GET/products/List with canonical ?query=<cdx_query.Query JSON>
GET/products/countCount matching rows
GET/products/groupedGrouped list by ?query= with group_by cdx.query.group.filter_bucket
GET/products/:id/positionPosition of a row in the current list window
GET/products/:idGet one
GET/products/_/schemaDescriptor metadata for clients

Mutating routes are controlled by the capability set:

CapabilityRoutes
EntityCrudCapability.createPOST /products/
EntityCrudCapability.updatePUT /products/:id
EntityCrudCapability.deleteDELETE /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

CapabilityAdds
timestampsAuto-stamps created_at and updated_at on writes
auditStamps actor columns and mounts GET /:id/audit
versioningGET /: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
savedViewsSeven /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

dart
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

dart
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.

dart
EntityCrudDescriptor<Product>(
  ...,
  customRoutes: (scope) {
    scope.get('/featured', listFeaturedProducts);
    scope.get('/:id/stock-status', stockStatus);
  },
);

Where to Go Next

  • Postgres — the DbPlugin used by CRUD today
  • Query?query= and scope compilation
  • OpenAPI — document generated CRUD routes