Skip to content

Commit

Permalink
Add Evolu monorepo with Evolu lib, server, and web
Browse files Browse the repository at this point in the history
  • Loading branch information
steida committed Sep 25, 2022
1 parent 6de31e7 commit 11a04d4
Show file tree
Hide file tree
Showing 69 changed files with 11,142 additions and 0 deletions.
9 changes: 9 additions & 0 deletions .eslintrc.js
@@ -0,0 +1,9 @@
module.exports = {
root: true,
extends: ["evolu"],
settings: {
next: {
rootDir: ["apps/*/"],
},
},
};
13 changes: 13 additions & 0 deletions .gitignore
@@ -0,0 +1,13 @@
.DS_Store
node_modules
.turbo
*.log
.next
dist
dist-ssr
*.local
.env
.cache
server/dist
public/dist
storybook-static/
8 changes: 8 additions & 0 deletions apps/server/.eslintrc.cjs
@@ -0,0 +1,8 @@
module.exports = {
root: true,
parserOptions: {
tsconfigRootDir: __dirname,
project: ["./tsconfig.json"],
},
extends: ["evolu"],
};
11 changes: 11 additions & 0 deletions apps/server/.gitignore
@@ -0,0 +1,11 @@
/node_modules
/dist

# misc
.DS_Store
*.pem

# typescript
*.tsbuildinfo

db.sqlite
3 changes: 3 additions & 0 deletions apps/server/README.md
@@ -0,0 +1,3 @@
# evolu server

Node.js Evolu server with Sqlite.
34 changes: 34 additions & 0 deletions apps/server/package.json
@@ -0,0 +1,34 @@
{
"name": "server",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsc && node dist/index.js",
"build": "rm -rf dist && tsc",
"start": "node dist/index.js",
"lint": "TIMING=1 eslint src --ext .ts,.tsx",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
"better-sqlite3": "^7.6.2",
"body-parser": "^1.20.0",
"cors": "^2.8.5",
"evolu": "workspace:0.0.0",
"express": "^4.18.1",
"fp-ts": "^2.12.3"
},
"devDependencies": {
"@evolu/tsconfig": "workspace:0.0.0",
"@types/better-sqlite3": "^7.6.0",
"@types/body-parser": "^1.19.2",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.14",
"@types/node": "^16.11.60",
"ts-node": "^10.9.1",
"typescript": "^4.8.3"
},
"engines": {
"node": ">=16.17"
}
}
258 changes: 258 additions & 0 deletions apps/server/src/index.ts
@@ -0,0 +1,258 @@
import sqlite3, { Statement } from "better-sqlite3";
import bodyParser from "body-parser";
import cors from "cors";
import {
createInitialMerkleTree,
createSyncTimestamp,
diffMerkleTrees,
EncryptedCrdtMessage,
insertIntoMerkleTree,
MerkleTree,
merkleTreeFromString,
MerkleTreeString,
merkleTreeToString,
Millis,
SyncRequest,
SyncResponse,
timestampFromString,
TimestampString,
timestampToString,
} from "evolu";
import express from "express";
import * as fpts from "fp-ts";
import { either, option, readerEither, readonlyArray } from "fp-ts";
import { ReaderEither } from "fp-ts/lib/ReaderEither.js";
import path from "path";

// A workaround until fp-ts 3 release.
const { flow, pipe } = fpts.function;

interface Db {
readonly begin: Statement;
readonly rollback: Statement;
readonly commit: Statement;
readonly selectMerkleTree: Statement;
readonly insertOrIgnoreIntoMessage: Statement;
readonly insertOrReplaceIntoMerkleTree: Statement;
readonly selectMessages: Statement;
}

interface DbEnv {
readonly db: Db;
}

interface ReqEnv {
readonly req: SyncRequest;
}

type DbAndReqEnvs = DbEnv & ReqEnv;

interface ParseBodyError {
readonly type: "ParseBodyError";
readonly error: unknown;
}

interface SqliteError {
readonly type: "SqliteError";
readonly error: unknown;
}

const createDb: (fileName: string) => Db = flow(
(fileName) => path.join(process.cwd(), "/", fileName),
sqlite3,
(sqlite) => {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS "message" (
"timestamp" TEXT,
"userId" TEXT,
"content" BLOB,
PRIMARY KEY(timestamp, userId)
);
CREATE TABLE IF NOT EXISTS "merkleTree" (
"userId" TEXT PRIMARY KEY,
"merkleTree" TEXT
);
`);

return {
begin: sqlite.prepare(`BEGIN`),
rollback: sqlite.prepare(`ROLLBACK`),
commit: sqlite.prepare(`COMMIT`),

selectMerkleTree: sqlite.prepare(
`SELECT "merkleTree" FROM "merkleTree" WHERE "userId" = ?`
),

insertOrIgnoreIntoMessage: sqlite.prepare(`
INSERT OR IGNORE INTO "message" (
"timestamp", "userId", "content"
) VALUES (?, ?, ?) ON CONFLICT DO NOTHING
`),

insertOrReplaceIntoMerkleTree: sqlite.prepare(`
INSERT OR REPLACE INTO "merkleTree" (
"userId", "merkleTree"
) VALUES (?, ?)
`),

selectMessages: sqlite.prepare(`
SELECT "timestamp", "content" FROM "message"
WHERE "userId" = ? AND "timestamp" > ? AND "timestamp" NOT LIKE '%' || ?
ORDER BY "timestamp"
`),
};
}
);

const sqliteErrorFromError = (error: unknown): SqliteError => ({
type: "SqliteError",
error,
});

const parseBody: ReaderEither<Uint8Array, ParseBodyError, ReqEnv> = (body) =>
pipe(
either.tryCatch(
() => SyncRequest.fromBinary(body),
(error): ParseBodyError => ({ type: "ParseBodyError", error })
),
either.map((req) => ({ req }))
);

const getMerkleTree: ReaderEither<DbAndReqEnvs, SqliteError, MerkleTree> = ({
db,
req,
}) =>
pipe(
either.tryCatch(
() =>
db.selectMerkleTree.get(req.userId) as
| { readonly merkleTree: MerkleTreeString }
| undefined,
sqliteErrorFromError
),
either.map((row) =>
row ? merkleTreeFromString(row.merkleTree) : createInitialMerkleTree()
)
);

const addMessages =
(
merkleTree: MerkleTree
): ReaderEither<DbAndReqEnvs, SqliteError, MerkleTree> =>
({ db, req }) =>
either.tryCatch(
() => {
if (req.messages.length === 0) return merkleTree;

db.begin.run();
req.messages.forEach((message) => {
const result = db.insertOrIgnoreIntoMessage.run(
message.timestamp,
req.userId,
message.content
);
if (result.changes === 1)
// eslint-disable-next-line no-param-reassign
merkleTree = insertIntoMerkleTree(
timestampFromString(message.timestamp as TimestampString)
)(merkleTree);
});
db.insertOrReplaceIntoMerkleTree.run(
req.userId,
merkleTreeToString(merkleTree)
);
db.commit.run();
return merkleTree;
},
(error): SqliteError => {
db.rollback.run();
return sqliteErrorFromError(error);
}
);

const getMessages =
({
merkleTree,
}: {
readonly merkleTree: MerkleTree;
}): ReaderEither<
DbAndReqEnvs,
SqliteError,
readonly EncryptedCrdtMessage[]
> =>
({ db, req }) =>
pipe(
diffMerkleTrees(
merkleTree,
merkleTreeFromString(req.merkleTree as MerkleTreeString)
),
// TODO: Remove `:Millis` with fp-ts 3
option.map((millis: Millis) =>
either.tryCatch(
() =>
db.selectMessages.all(
req.userId,
pipe(millis, createSyncTimestamp, timestampToString),
req.nodeId
) as readonly EncryptedCrdtMessage[],
sqliteErrorFromError
)
),
option.getOrElseW(() => either.right(readonlyArray.empty))
);

const sync: ReaderEither<
DbAndReqEnvs,
SqliteError,
{
readonly merkleTree: MerkleTree;
readonly messages: readonly EncryptedCrdtMessage[];
}
> = pipe(
getMerkleTree,
readerEither.chain(addMessages),
readerEither.bindTo("merkleTree"),
readerEither.bind("messages", getMessages)
);

const dbEnv: DbEnv = { db: createDb("db.sqlite") };

const app = express();
app.use(cors());
app.use(bodyParser.raw({ limit: "20mb" }));

app.post("/", (req, res) => {
pipe(
parseBody(req.body),
either.map((reqEnv): DbAndReqEnvs => ({ ...dbEnv, ...reqEnv })),
either.chainW(sync),
either.match(
(error) => {
// eslint-disable-next-line no-console
console.log(error);
res.status(500).json("oh noes!");
},
({ merkleTree, messages }) => {
res.setHeader("Content-Type", "application/octet-stream");
res.send(
Buffer.from(
SyncResponse.toBinary({
merkleTree: merkleTreeToString(merkleTree),
messages: [...messages], // to mutable array
})
)
);
}
)
);
});

app.get("/ping", (_req, res) => {
res.send("ok");
});

const port = 4000;
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`Server is listening at http://localhost:${port}`);
});
10 changes: 10 additions & 0 deletions apps/server/tsconfig.json
@@ -0,0 +1,10 @@
{
"extends": "@evolu/tsconfig/universal-esm.json",
"compilerOptions": {
"outDir": "dist",
"module": "Node16",
"moduleResolution": "Node16"
},
"include": ["src"],
"exclude": ["node_modules"]
}
8 changes: 8 additions & 0 deletions apps/web/.eslintrc.js
@@ -0,0 +1,8 @@
module.exports = {
root: true,
parserOptions: {
tsconfigRootDir: __dirname,
project: ["./tsconfig.json"],
},
extends: ["evolu"],
};
30 changes: 30 additions & 0 deletions apps/web/README.md
@@ -0,0 +1,30 @@
## Getting Started

First, run the development server:

```bash
yarn dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.

[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`.

The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!

## Deploy on Vercel

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

0 comments on commit 11a04d4

Please sign in to comment.