diff --git a/.gitignore b/.gitignore index 7721393..0003ad8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ node_modules/ *.db *.db-wal *.db-shm +*-wal +*-shm /static/uploads diff --git a/TablesForModels.ts b/TablesForModels.ts new file mode 100644 index 0000000..092abe3 --- /dev/null +++ b/TablesForModels.ts @@ -0,0 +1,9 @@ +import { Display, Playlist, Song, Team, User } from '@lyrics/models.ts' + +export const TablesForModels = { + 'song': Song, + 'playlist': Playlist, + 'display': Display, + 'user': User, + 'team': Team, +} diff --git a/api.ts b/api.ts index 82cf58e..394cb33 100644 --- a/api.ts +++ b/api.ts @@ -2,6 +2,12 @@ import { FreshContext, Handlers } from '$fresh/server.ts' import { db, TablesForModels } from '@lyrics/db.ts' import { ulid } from 'https://deno.land/std@0.209.0/ulid/mod.ts' +export type Method = 'GET' | 'PUT' | 'DELETE' + +export function isMethod(value: string): value is Method { + return (value in TablesForModels) +} + export function crudHandlerFor< T extends { id: string }, M extends { parse: (a: unknown) => T }, diff --git a/db.ts b/db.ts index b3f25a1..5de91a6 100644 --- a/db.ts +++ b/db.ts @@ -1,4 +1,4 @@ -import { Display, Playlist, Song } from '@lyrics/models.ts' +import { AuthUser, Display, Playlist, Song, Team } from '@lyrics/models.ts' export const kv = await Deno.openKv('lyrics') @@ -6,12 +6,14 @@ export const TablesForModels = { 'song': Song, 'playlist': Playlist, 'display': Display, + 'user': AuthUser, + 'team': Team, } export type Table = keyof typeof TablesForModels function crudFor< - P extends keyof typeof TablesForModels, T extends { id: string }, + P extends keyof typeof TablesForModels, M extends { parse: (a: unknown) => T }, >(m: M, p: P) { return { @@ -42,6 +44,11 @@ export function isTable(value: string): value is Table { export const db = { song: crudFor(Song, 'song'), - playlist: crudFor(Playlist, 'playlist'), + playlist: crudFor( + Playlist, + 'playlist', + ), display: crudFor(Display, 'display'), + user: crudFor(AuthUser, 'user'), + team: crudFor(Team, 'team'), } diff --git a/deno.json b/deno.json index aaf6be6..5135194 100644 --- a/deno.json +++ b/deno.json @@ -21,16 +21,18 @@ "**/_fresh/*" ], "imports": { + "zod": "https://deno.land/x/zod@v3.22.4/mod.ts", + "saurpc": "https://deno.land/x/saurpc@0.5.0/mod.ts", + "bcrypt": "https://deno.land/x/bcrypt@v0.4.1/mod.ts", "$fresh/": "https://deno.land/x/fresh@1.6.5/", "preact": "https://esm.sh/preact@10.19.2", "preact/": "https://esm.sh/preact@10.19.2/", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.1", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.0", "tailwindcss": "npm:tailwindcss@3.4.1", - "tailwindcss/": "npm:/tailwindcss@3.4.1/", "tailwindcss/plugin": "npm:/tailwindcss@3.4.1/plugin.js", "@lyrics/": "./", - "$std/": "https://deno.land/std@0.211.0/" + "$std/": "https://deno.land/std@0.219.0/" }, "compilerOptions": { "jsx": "react-jsx", diff --git a/flake.lock b/flake.lock index e985e3a..9da4bc5 100644 --- a/flake.lock +++ b/flake.lock @@ -2,16 +2,16 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1708118438, - "narHash": "sha256-kk9/0nuVgA220FcqH/D2xaN6uGyHp/zoxPNUmPCMmEE=", + "lastModified": 1710085324, + "narHash": "sha256-OV+dGVjOFuGrdutv+7SFNf+8ArJE3QuUKPKeRziZVVY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5863c27340ba4de8f83e7e3c023b9599c3cb3c80", + "rev": "91686dd2ff9e091f5010241de9d1de0a9388c7c8", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "master", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index bb1bc01..d4c8ea7 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/master"; outputs = { self, nixpkgs, diff --git a/fresh.gen.ts b/fresh.gen.ts index 3581199..2e61aa7 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -7,6 +7,7 @@ import * as $_app from './routes/_app.tsx' import * as $api_db from './routes/api/db.ts' import * as $api_db_table_ from './routes/api/db/[table].ts' import * as $api_joke from './routes/api/joke.ts' +import * as $api_rpc from './routes/api/rpc.ts' import * as $greet_name_ from './routes/greet/[name].tsx' import * as $index from './routes/index.tsx' import * as $Counter from './islands/Counter.tsx' @@ -19,6 +20,7 @@ const manifest = { './routes/api/db.ts': $api_db, './routes/api/db/[table].ts': $api_db_table_, './routes/api/joke.ts': $api_joke, + './routes/api/rpc.ts': $api_rpc, './routes/greet/[name].tsx': $greet_name_, './routes/index.tsx': $index, }, diff --git a/models.ts b/models.ts index 82dceb2..792d109 100644 --- a/models.ts +++ b/models.ts @@ -1,10 +1,49 @@ -import { z } from 'https://deno.land/x/zod@v3.22.4/mod.ts' +import { z } from 'zod' +// shared export const Identifiable = z.object({ - id: z.string().uuid(), + id: z.string().ulid(), }) export type TIdentifiable = z.infer +export const Creatable = z.object({ + createdAt: z.date(), +}) +export type TCreatable = z.infer + +export const Updatable = z.object({ + updatedAt: z.date(), +}) +export type TUpdatable = z.infer + +// lyrics +export const Role = z.enum([ + 'admin', // full permissions + 'editor', // edit songs, verses, displays, playlists, etc. + 'viewer', // cannot change anything, but can view everything +]) +export type TRole = z.infer + +export const Team = Identifiable.merge(Creatable).merge(z.object({ + name: z.string(), + members: z.record(z.string().ulid(), z.set(Role)), +})) +export type TTeam = z.infer + +export const User = Identifiable.merge(Creatable).merge(z.object({ + username: z.string(), +})) +export type TUser = z.infer + +export const AuthUser = User.merge(z.object({ + passwordDigest: z.string(), +})) +export type TAuthUser = z.infer + +export function toUser({ passwordDigest: _, ...user }: TAuthUser): TUser { + return user +} + export const Verse = z.object({ content: z.string(), }) @@ -16,25 +55,27 @@ export const Map = z.object({ export type TMap = z.infer export const Song = Identifiable.merge(z.object({ - title: z.string(), + name: z.string(), verses: z.record(z.string(), Verse), maps: z.record(z.string(), Map), })) export type TSong = z.infer export const PlaylistEntry = z.object({ - songKey: z.string().uuid(), + songId: z.string().ulid(), mapKey: z.string(), }) export type TPlaylistEntry = z.infer export const Playlist = Identifiable.merge(z.object({ + name: z.string(), entries: z.array(PlaylistEntry), })) export type TPlaylist = z.infer export const Display = Identifiable.merge(z.object({ - playlistKey: z.string().uuid(), - songKeyIndex: z.number(), + name: z.string(), + playlistId: z.string().ulid(), + songIndex: z.number(), })) export type TDisplay = z.infer diff --git a/routes/api/db/[table].ts b/routes/api/db/[table].ts index 9eb5e18..b9f9472 100644 --- a/routes/api/db/[table].ts +++ b/routes/api/db/[table].ts @@ -1,5 +1,4 @@ -import { FreshContext, Handlers } from '$fresh/server.ts' -import { knownMethods } from '$fresh/server/router.ts' +import { FreshContext } from '$fresh/server.ts' import { isTable } from '@lyrics/db.ts' import { crudHandlerFor } from '@lyrics/api.ts' import { Display, Playlist, Song } from '@lyrics/models.ts' @@ -10,10 +9,6 @@ export const subHandlers = { display: crudHandlerFor(Display, 'display'), } -export function isMethod(value: string): value is Table { - return (value in []) -} - export const handler = (req: Request, ctx: FreshContext): Response => { const table = ctx.params.table req.method diff --git a/routes/api/rpc.ts b/routes/api/rpc.ts new file mode 100644 index 0000000..9f0f8a4 --- /dev/null +++ b/routes/api/rpc.ts @@ -0,0 +1,51 @@ +// import { FreshContext } from '$fresh/server.ts' +import { TablesForModels } from '@lyrics/db.ts' +import { handleRpcRequest, ProcedureServerError } from 'saurpc' +import { rpcs } from '@lyrics/rpc.ts' + +export type Method = 'GET' | 'PUT' | 'DELETE' + +export function isMethod(value: string): value is Method { + return (value in TablesForModels) +} + +function toErrorResponse(e: ProcedureServerError) { + const { type, message } = e + const headers = { 'content-type': 'application/json' } + switch (type) { + case 'rpc_not_found': + console.info('RPC Not Found:', e) + return new Response( + JSON.stringify({ type, message }), + { status: 404, headers }, + ) + case 'invalid_request': + console.info('Invalid RPC:', e) + return new Response( + JSON.stringify({ type, message }), + { status: 400, headers }, + ) + case 'exception_caught': + console.error('RPC Exception:', e) + return new Response( + JSON.stringify({ type }), + { status: 500, headers }, + ) + } +} + +export const handler = async ( + req: Request, + // _ctx: FreshContext, +): Promise => { + try { + return await handleRpcRequest(req, rpcs) + } catch (e) { + if (e instanceof ProcedureServerError) { + return toErrorResponse(e) + } else { + console.error('Error handling RPC request:', e) + return new Response('unknown error occurred', { status: 500 }) + } + } +} diff --git a/rpc.ts b/rpc.ts new file mode 100644 index 0000000..1bb325f --- /dev/null +++ b/rpc.ts @@ -0,0 +1,27 @@ +import { hash } from 'bcrypt' +// import { User } from './models.ts' +import { TAuthUser, toUser, TUser } from '@lyrics/models.ts' +import { ulid } from '$std/ulid/mod.ts' +import { db } from '@lyrics/db.ts' + +export const rpcs = { + 'ping': (): 'pong' => { + console.log('received ping') + return 'pong' + }, + 'getVersion': () => '0.1.0', + async registerUser( + username: string, + password: string, + ): Promise { + const user: TAuthUser = { + id: ulid(), + createdAt: new Date(), + username: username, + passwordDigest: await hash(password), + } + const result = await db.user.save(user) + if (!result.ok) throw new Error('failed to save user') + return toUser(user) + }, +}