workout-builder3 Svelte Themes

Workout Builder3

SvelteKit CRUD app template: typed entities, form validation, API

SvelteKit Template

This project contains a working, mostly fleshed-out template for a SvelteKit app. It performs CRUD on entities, or domain objects, the nouns in your app. The example models a workout, like at the gym. A Workout contains an ordered collection of Sets. A Set is made up of an ordered collection of Activities. Activities can be Exercises or Rest and have a duration, measured in a number of seconds.

Entities

The entities are modeled as TypeScript types in $lib/entities.d.ts. In addition to its strongly typed definition, each entity has a Pending version. Pending entities are loosely typed, allowing them to be bound to inputs, for example, in a HTML form that only supports string inputs (well, FormData).

---
# title: Workout Builder
# config:
  # layout: elk
  # look: handDrawn
---

erDiagram
    Workout {
        string label
        string name
        string description
    }
    Set {
        string label
        string name
    }
    Activity {
        number duration
        string[] instructions
    }
    Exercise {
        string label
        string name
        string description
    }
    Rest { }

    Workout ONE TO ONE OR MORE Set : "has"
    Set ONE TO ONE OR MORE Activity : "has"
    Activity ||..|| Exercise : "is"
    Activity ||..|| Rest : "is"
    Exercise ONE TO ZERO OR MORE Exercise : "alternatives"

CRUD

Each entity has the following default routes:

  • /entities: Lists all instances, allows clicking into
    • /[label]: An individual entity instance, by default, read-only
      • /edit: A form view that allows for updating an individual instance
    • /new/: A form view to create a new instance. A POST redirects to /eneties/[label] or /eneties/[label]/edit.

Data Access

All data access goes through an API library, $lib/server/api.js. The API is responsible for encapsulating the database and enforcing business rules. The $lib/server path ensures that it‘s not executed on the client.

Validation

Enforcing business rules, such as validating user inputs or handling database constraint violations, are communicated as part of an API’s return types. And InvalidResult type return allows a function to return the user input and a collection of one or more validation errors. APIs should only throw (or bubble) exceptions for unexpected states that the user cannot fix themselves by submitting different data. An empty value for a required property is a validation error, not an exceptional case. The user should resubmit with a different value. A dropped database connection, on the other hand, is an error state that the user can’t do anything about.

Example API function, $lib/server/api.js
/**
 * (1)
 * @param {PendingEntity} input
 * (2)
 * @returns {Promise<Result<PendingEntity, Entity, 'entity'>>}
 */
export async function create_entity(input) {
  /** @type {Validation<Entity>[]} */
  const validations = [];
  // (3)
  if (!test(input.property)) 
    validations.push({ message: 'Name is required', for: 'property' });
  // (4)
  if (has(validations)) {
    return { entity: input, validations };
  }
  // (5)
  const entity = await /** @type {Entity} */ (
    db.query(`INSERT entity RETURNING …`);
  );
  return entity;
}
  1. Inputs typcially use the Pending version of the entity type. This allows (mostly) straightforward mapping from FormData in the calling form handler.
  2. APIs that perform validation use the Result<In, Out, Prop> return type to convey return values.
    • In is the type of input, for CRUD operations, usually the Pending version of an entity
    • Out is the expected return type, often the strongly typed form of the pending input. Postel’s Law: “Be conservative in what you send, be liberal in what you accept from others.”
    • Prop is the string name of the property in which the input will be stashed in the InvalidResult instance.
  3. Page handlers, +page.server.js, should be “dumb”. They should be responsible for collecting data from the UI and passing to the appropriate API. APIs implement the valaidation logic. Validation errors return InvalidResult instances. Page handlers can use the is_valid() guard function to differentiate between a Result and InvalidResult type.
  4. In the happy path, where the input is valid, the return value does not need to be wrapped. This is equivalent to a Entity return type. Thus APIs that don’t do any validation do not need to do the Result/InvalidResult rigamarole.
Page handler, +page.server.js, form actions
/** @satisfies {import('./$types').Actions} */
export const actions = {
  create: async ({ request }) => {
    // (1)
    const entity_input = /** @type {PendingEntity} */ (
      Object.fromEntries(await request.formData())
    );
    // (2)
    const entity = await api.create_entity(entity_input);
    // (3)
    if (is_invalid(entity)) return fail(400, entity);
    //return { entity };
    return redirect(303, `/entitys/${entity.label}`);
  }
};
  1. Marshall a Pending entity from the submitted FormData. More complex objects or form abstractions might need specific mapping logic. The type assertion is a little heavy handed. However, FormData is difficult to correctly tpye.
  2. Uses the API to turn the Pending entity into its stongly typed instance.
  3. Validation failures use SvelteKit’s fail() response to convey an HTTP 400 error that’s available to the page in the form property of $props(). Validation errors get passed through the fail() Response and are available as form?.validations in the front-end. Exceptions return a 500 error(). Generally errors should be allowed to bubble and handled at the closest parent error boundary.

Top categories

Loading Svelte Themes