/ivo

A user story-focused event-driven schema validator for JS/TS backends

Primary LanguageTypeScriptMIT LicenseMIT

Foreword

ivo is a user story focused event-driven schema validator which provides an interface for you to clearly define the behaviour of your entities at creation and during updates

Installation

$ npm i ivo

Importing

// CJS
const { Schema } = require('ivo');

// ESM
import { Schema } from 'ivo';

Defining a schema

import { Schema, type MutableSummary } from 'ivo';

type UserInput = {
  email: string;
  username: string;
  phoneNumber: string;
};

type User = {
  id: string;
  createdAt: Date;
  email: string | null;
  username: string;
  phoneNumber: string | null;
  updatedAt: Date;
  usernameUpdatableFrom: Date | null;
};

const userSchema = new Schema<UserInput, User>(
  {
    id: { constant: true, value: generateUserID },
    email: {
      default: null,
      required: isEmailOrPhoneRequired,
      validator: [validateEmail, makeSureEmailIsUnique],
    },
    phoneNumber: {
      default: null,
      required: isEmailOrPhoneRequired,
      validator: validatePhoneNumber,
    },
    username: {
      required: true,
      validator: [validateUsername, makeSureUsernameIsUnique],
      shouldUpdate({ usernameUpdatableFrom }) {
        if (!usernameUpdatableFrom) return true;

        return (
          new Date().getTime() >= new Date(usernameUpdatableFrom).getTime()
        );
      },
    },
    usernameUpdatableFrom: {
      default: null,
      dependsOn: 'username',
      resolver({ isUpdate }) {
        if (!isUpdate) return null;

        const now = new Date();
        now.setDate(now.getDate() + 30);

        return now;
      },
    },
  },
  { timestamps: true },
);

function isEmailOrPhoneRequired({
  context: { email, phoneNumber },
}: MutableSummary<UserInput, User>) {
  return [!email && !phoneNumber, 'Provide "email" or "phone" number'] as const;
}

async function makeSureEmailIsUnique(email: string) {
  const userWithEmail = await usersDb.findByEmail(email);

  return userWithEmail ? { valid: false, reason: 'Email already taken' } : true;
}

async function makeSureUsernameIsUnique(username: string) {
  const userWithUsername = await usersDb.findByUsername(username);

  return userWithUsername
    ? { valid: false, reason: 'Username already taken' }
    : true;
}

// get the model
const UserModel = userSchema.getModel();

Creating an entity

const { data, error } = await UserModel.create({
  email: 'john.doe@mail.com',
  id: 5, // will be ignored because it is a constant property
  name: 'John Doe', // will be ignored because it is not on schema
  username: 'john_doe',
  usernameUpdatableFrom: new Date(), // will be ignored because it is a dependent property
});

if (error) return handleError(error);

console.log(data);
// {
//   createdAt: new Date(),
//   email: 'john.doe@mail.com',
//   id: 101,
//   phoneNumber: null,
//   updatedAt: new Date(),
//   username: 'john_doe',
//   usernameUpdatableFrom: null
// }

// data is safe to dump in db
await usersDb.insertOne(data);

Updating an entity

const user = await usersDb.findByID(101);

if (!user) return handleError({ message: 'User not found' });

const { data, error } = await UserModel.update(user, {
  usernameUpdatableFrom: new Date(), // dependent property -> will be ignored
  id: 75, // constant property -> will be ignored
  age: 34, // not on schema -> will be ignored
  username: 'johndoe',
});

if (error) return handleError(error);

console.log(data);
// {
//   username: 'johndoe',
//   usernameUpdatableFrom: Date, // value returned from resolver -> 30days from now
//   updatedAt: new Date()
// }

await usersDb.updateByID(user.id, data);
// updating 'username' again will not work

const { error } = await UserModel.update(user, {
  username: 'john-doe', // will be ignored because shouldUpdate rule will return false
});

console.log(error);
// {
//   message: 'NOTHING_TO_UPDATE',
//   payload: {}
// }

Docs