JS20

Documentation

JS20 - Documentation

Example app

Let's build a small app called BookMate โ€” a simple backend where users can track the books they read and leave reviews.


With just a few lines of code, JS20 will handle auth, validation, ACL, CRUD, and even generate a type-safe frontend SDK automatically.

๐Ÿ“‹ Requirements

Requirements for our example app BookMate.

  • Users can log in and manage their personal book list.
  • Each book stores its title, author, finished status, optional average rating, and category.
  • Category is one of: Fiction, Non-Fiction, Science, History or Fantasy.
  • Users can write short reviews with a comment and star rating.
  • Limit each user to 20 books.
  • Validate that ratings are between 1โ€“5.
  • Automatically recalculate each userโ€™s average book rating after reviews.

Code

Here's the complete code for our app:

import path from 'path';
import * as dotenv from 'dotenv';

import { App, type Model, type MysqlConnectOptions, MySqlDatabase, BetterAuth, Schema, sMessage } from '@js20/core'
import { sString, sBoolean, sInteger, sEnum, sNumber } from '@js20/schema'

dotenv.config({ quiet: true });


// ------------ Types ------------------

enum BookCategory {
    Fiction = 'Fiction',
    NonFiction = 'NonFiction',
    Science = 'Science',
    History = 'History',
    Fantasy = 'Fantasy',
}

interface Book {
    title: string;
    author: string;
    isFinished: boolean;
    averageRating?: number;
    category: BookCategory;
}

interface Review {
    bookId: string;
    comment: string;
    stars: number;
}

interface Models {
    book: Model<Book>;
    review: Model<Review>;
}


// ------------ Schemas ------------------

const sBook: Book = {
    title: sString().maxLength(255).type(),
    author: sString().maxLength(255).type(),
    isFinished: sBoolean().type(),
    averageRating: sNumber().min(1).max(5).optional().type(),
    category: sEnum<BookCategory>(BookCategory).type(),
}

const sReview: Review = {
    bookId: sString().matches(/^[0-9a-fA-F-]{36}$/).type(),
    comment: sString().maxLength(500).type(),
    stars: sInteger().min(1).max(5).type(),
}


// ------------ Models ------------------

const models: Models = {
    book: {
        name: 'book',
        schema: sBook,
    },
    review: {
        name: 'review',
        schema: sReview,
    },
}


// ------------ App ------------------

const app = new App<Models>();


// ----------- Actions ------------------

const assertBookLimit = app.action({
    outputSchema: sMessage,
    run: async (system) => {
        const maxBooksPerUser = 20;
        const count = await system.models.book.count();

        if (count >= maxBooksPerUser) {
            throw new Error(`You can only create up to ${maxBooksPerUser} books.`);
        }

        return { message: 'Ok' };
    },
});

const updateAverageRating = app.action({
    inputSchema: Schema.withInstance(sBook),
    outputSchema: sMessage,
    run: async (system, input) => {
        // Get all reviews for the book, bypassing "where ownerId = user.id"
        const reviews = await system.bypassAcl.models.review.getAll({
            bookId: input.id,
        });

        if (!reviews.length) {
            return { 
                message: 'No reviews found for book'
            };
        }

        const sum = reviews.reduce((acc, r: any) => acc + Number(r.stars || 0), 0);
        const avg = sum / reviews.length;

        await system.models.book.updateById(input.id, {
            averageRating: Math.round(avg),
        });

        return { 
            message: `Updated average rating to ${Math.round(avg)}`
        };
    }
});


// ----------- Endpoints ------------------

// Adds get, list, create, update & delete endpoints
app.addCrudEndpoints(models.book, {
    actions: {
        createBefore: assertBookLimit,
    }
});

// Adds get, list, create, update & delete endpoints
app.addCrudEndpoints(models.review, {
    actions: {
        createAfter: updateAverageRating,
        updateAfter: updateAverageRating,
        deleteAfter: updateAverageRating,
    }
});


// ----------- Database ------------------

const connectOptions: MysqlConnectOptions = {
    host: process.env.SQL_HOST || '',
    port: parseInt(process.env.SQL_PORT || '3306'),
    user: process.env.SQL_USER || '',
    password: process.env.SQL_PASSWORD || '',
    database: process.env.SQL_DATABASE || '',
};

const database = new MySqlDatabase(connectOptions, {
    initializeTables: true
});

database.addModels(models);
app.addDatabase(database);


// ---------- Auth ------------------

const auth = new BetterAuth(database, {
    useEmailPassword: true
});

app.setAuthenticator(auth);


// ---------- Start! ------------------
app.start();

We can also call generate() to automatically create the frontend SDK:

await app.generate({
    entryPath: path.resolve('./src/examples/raw/example-app.ts'),
    outputs: ['./dist/frontend.ts'],
    appName: 'BookMate',
    version: '1.0.0',
    comment: 'This code is generated from src/examples/raw/example-app.ts',
    baseUrl: 'http://localhost:3000',
});

Logs from the generate command

Outputted Read-Only file containing frontend SDK

JS20