for mongodb (will support postgres, and DenoKV in the future) heavy inspired on firebase, pocketbase and supabase.
Important
(READ): The documentation is still under development so not all the functionalities of reactivedb are yet documented.
History: The idea of ReactiveDB was born at the beginning of 2020 while I was working for a software company, the project I was in needed to have data synchronization between multiple users, originally we built everything around firebase which worked perfectly, but for client requirements it was necessary to have the entire infrastructure running on my own servers locally, so I was faced with the task of thinking of a solution that would meet these requirements and at the same time allow us not to have to change everything that was already closely coupled to the design from firebase, so ReactiveDB was born from here.
ReactiveDB aims, like firebase, supabase, poketbase, to provide a fast backend structure and listen to the changes that occur in the database in real time, but ReactiveDB takes the idea of being a framework/library which makes it easy to integrate it into a project already running or also start a project from scratch.
example
import{ReactiveCore,Crypto}from"https://deno.land/x/reactivedb/mod.ts";import{load}from"https://deno.land/std/dotenv/mod.ts";// load envsconst{REACTIVE_SERVER_PORT,REACTIVE_JWK_BASE_64,REACTIVE_DB_CONNECTION,REACTIVE_DB_NAME,}=awaitload();constcrypt=newCrypto({name: "HMAC",hash: "SHA-512"},true,["sign","verify",]);constsecretKey=awaitcrypt.importFromJWKBase64(REACTIVE_JWK_BASE_64);// start reactivedbawaitReactiveCore({connection: REACTIVE_DB_CONNECTION,// mongodb+srv://<username>:<password>@<cluster-address>/?retryWrites=true&w=majorityport: Number(REACTIVE_SERVER_PORT),// 4000database: REACTIVE_DB_NAME,// <database-name>CLSDefinition: rules,
secretKey,});With these few lines of code you can have a complete backend and real-time connection with the collections within the database.
If you have a collection let's say "Users" in the database, reactive db will automatically create a rest api and a websockets channel to read and write data automatically.
example: client deno
import{createClient,Auth,}from"https://deno.land/x/reactivedb/client/mod.ts";consturl="http://localhost:8080";constauth=newAuth(url);// login and authconsttoken=awaitauth.loginWithEmailAndPassword("email@example.com","12345678");// create instanceconstReactiveDB=createClient(url,token!);// create a clientconstclient=ReactiveDB();// connecto to a channel or collection from databaseclient.connectTo("Users",()=>console.log("connected to Users"));// listen events from collectionclient.on("child_added",(data,event)=>{console.log({ data, event });});// perform actions if connection closeclient.onClose(()=>{console.log("disconnected from Users");});and also exist a Restfull api to all collections
example
GET: http://localhost:8080/v1/Users -> get all docs from Users collections
POST: http://localhost:8080/v1/Users '{ "name": "Jhon Doe", "password": "1234" }' -> push new data on Users collection
GET: http://localhost:8080/v1/Users/12345 -> get doc on collection by doc id
PUT, PATCH: http://localhost:8080/v1/Users/12345 '{ "name": "Jhon Smith", "password": "1234" }' -> update the doc from the collection by doc id
This rest api is automatic for all collections within the database, using the api automatically sends a notification to clients that are listening for changes.
first install ReactiveDB:
import{ReactiveCore}from"https://deno.land/x/reactivedb/mod.ts";or using Trex
trex install reactivedbnow create a database on mongodb atlas or use a local mongodb server:
import{ReactiveCore,Crypto}from"https://deno.land/x/reactivedb/mod.ts";import{load}from"https://deno.land/std/dotenv/mod.ts";// load envs - we strongly recommend using environment variablesconst{REACTIVE_SERVER_PORT,REACTIVE_JWK_BASE_64,REACTIVE_DB_CONNECTION,REACTIVE_DB_NAME,}=awaitload();then generate a secure CryptoKey:
import{Crypto}from"https://deno.land/x/reactivedb/mod.ts";constcrypto=newCrypto({name: "HMAC",hash: "SHA-512"},true,["sign","verify",]);awaitcrypto.generateKey();const{ toBase64 }=awaitcrypto.exportToJWKBase64();console.log(toBase64());// eyJrdHkiOiJvY3QiLCJrIjoib040ek5FRmhrVEdkaWJTdHpOMTZwZ1.....We recommend saving this token in an environment variable and not sharing it since the token is used to sign and verify JWTs.
.env
REACTIVE_JWK_BASE_64="eyJrdHkiOiJvY3QiLCJrIjoib040ek5FRmhrVEdkaWJTdHpOMTZwZ1....."Note: Each CryptoKey is unique, so if it is changed to a new one, it will not be possible to verify the tokens created with the previous CryptoKey.
now load the CryptoKey:
import{ReactiveCore,Crypto}from"https://deno.land/x/reactivedb/mod.ts";import{load}from"https://deno.land/std/dotenv/mod.ts";// load envsconst{REACTIVE_SERVER_PORT,REACTIVE_JWK_BASE_64,REACTIVE_DB_CONNECTION,REACTIVE_DB_NAME,}=awaitload();constcrypt=newCrypto({name: "HMAC",hash: "SHA-512"},true,["sign","verify",]);// load the CryptoKey from previous stepconstsecretKey=awaitcrypt.importFromJWKBase64(REACTIVE_JWK_BASE_64);// start reactivedbawaitReactiveCore({connection: REACTIVE_DB_CONNECTION,// mongodb+srv://<username>:<password>@<cluster-address>/?retryWrites=true&w=majorityport: Number(REACTIVE_SERVER_PORT),// 4000database: REACTIVE_DB_NAME,// <database-name>CLSDefinition: rules,
secretKey,});with ReactiveDB you can create your own endpoints called "Functions" these endpoints are structured using file system routing.
To create functions you just have to create a "functions" folder at the root of the project.
functions/
This is the anatomy of a reactive db function:
functions/users.[get].ts
users: the name of the
endpoint/function-> http://localhost:8080/users,indexname is transformed to/[ get ] : the http methods supported by the
endpoint/function-> all methods supported: get, post, put, delete, all. You can combine several methods for the same function:users.[get,post,put].tsor allow all methodsusers.[all].ts.ts-> file extension, supported file extensions: ts, js, tsx, jsx
Inside of the function looks like this:
functions/user.[get].ts
importtype{Context,Utilities,Middleware,}from"https://deno.land/x/reactivedb/mod.ts";import{Handler,HandlerMiddlewares,}from"https://deno.land/x/reactivedb/mod.ts";// middlewares for this functionexportconstmiddlewares=HandlerMiddlewares([async(ctx,next)=>{console.log(ctx.request.ip);awaitnext();},]);// functionexportdefaultasyncfunctionIndex(context: Context,utils: Utilities){try{constcursor=awaitutils.Database.collection("Users");constresults=awaitcursor.find(undefined,{noCursorTimeout: false}).toArray();utils.Events.post({to: "Users",data: [],event: "child_added",});context.response.status=200;context.response.body=results;}catch(error){console.log(error);}}Now we will explain each part:
Note: You can only export a single function per file and it must be exported by default:
exportdefaultasyncfunctionFn(){
....}Each function receives two parameters: Context and Utilities:
Context: In the context are theResponseand theRequestof each http request, reactivedb is built on top of the Oak framework so it is managed like any app made in Oak
exportdefaultasyncfunctionFn(context: Context){constbody=context.request.body({type: "json"});context.response.status=200;context.response.body={ok: true,};}Utilities: The utilities contain the connection to the database to make queries and add data to the collections. They also contain the method to dispatch events for the websocket connections that are listening.
exportdefaultasyncfunctionFn(context: Context,utils: Utilities){try{// connect to Users collectionconstcursor=awaitutils.Database.collection("Users");// get all users from Users collectionconstresults=awaitcursor.find(undefined,{noCursorTimeout: false}).toArray();// send to Users listenersutils.Events.post({to: "Users",data: [],event: "child_added",});context.response.status=200;context.response.body={ok: true,};}catch(error){console.log(error);}}The functions support middlewares, so you can define all the middlewares you need in each function.
// middlewares listexportconstmiddlewares=HandlerMiddlewares([async(ctx,next)=>{console.log(ctx.request.ip);awaitnext();},]);exportdefaultasyncfunctionFn(context: Context,utils: Utilities){
....}It is important that the next function is called in each middleware, since if it is not done, the execution will not continue to the other middlewares and towards the function.
exportconstmiddlewares=HandlerMiddlewares([async(ctx,next)=>{console.log(ctx.request.ip);awaitnext();// call it always},]);Note: You must always export with the name
middlewaresso that the middlewares are loaded before the execution of the function.
exportconstmiddlewares=HandlerMiddlewares([
....]);ReactiveDB implements a simple mechanism to write rules that protect database writes and reads, these rules are heavily inspired by Cloud Firestore Security Rules and Row Level Security Supabase
CLS allows you to create detailed rules for reading, writing, updating and deleting objects in collections.
example
import{ReactiveCore,CLSBuilder}from"https://deno.land/x/reactivedb/mod.ts";
....construles=CLSBuilder(()=>({"/:collection/:id": (params,db,context)=>{return{asyncread(){// match and get rule paramsconst{ id, collection }=params<"/:collection/:id">();constitems=db.collection(collection);constdoc=awaititems.findOne({_id: newBson.ObjectId(id)})!;// get userconstuserReq=awaititems.findOne({uuid: context.uuid})!;const{ uuid }=context;if(!userReq){// disallow read if not exist this userreturnfalse;}returntrue;},write(){return{create(){// allow to createreturntrue;},delete(){returnfalse;},update(){returnfalse;},};},};},}));
....awaitReactiveCore({connection: REACTIVE_DB_CONNECTION,// mongodb+srv://<username>:<password>@<cluster-address>/?retryWrites=true&w=majorityport: Number(REACTIVE_SERVER_PORT),// 4000database: REACTIVE_DB_NAME,// <database-name>CLSDefinition: rules,
secretKey,});Note: Security rules do not apply to the websockets(for now) connection, they only apply to HTTP API(v1)
you can create all the rules you want to catch all the conditions you need.
These are all the tools that are available to define the rules:
params: the parameters of the route to intercept, you can pass the route to match to the function in the form of a generic type, so that it is typed and returns the parameters the same as the route.
const{ id, collection }=params<"/:collection/:id">();^^^^-----------------------^^^^||------------------------------------------db: contain the connection to the database to make queries.
constitems=db.collection(collection);constdoc=awaititems.findOne({_id: newBson.ObjectId(id)})!;context: contains the information of the user who is accessing the database.
context;// { uuid: string; email: string; auth: boolean; }Write rules allow you to create individual rules to update, create, and delete data. but if you want you can make a single global rule for writing.
write(){return{create(){returntrue;},delete(){returnfalse;},update(){returnfalse;},};}// orwrite(){returntrue;}The read function can be asynchronous or not, but it must always return a boolean data:
read(): Promise<boolean>|boolean;For the write function, the function itself can be asynchronous or not, but it always has to return either a boolean data or a definition of write, update, delete:
write(): Promise<boolean>|Promise<WriteDefinition>|boolean|WriteDefinition;for write functions for writes these may or may not be asynchronous but must always return a boolean value:
create(): Promise<boolean>|boolean;delete(): Promise<boolean>|boolean;update(): Promise<boolean>|boolean;