mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-11-27 14:55:20 +03:00
Add pg-boss as a Job executor (#582)
Note: This does not provide cron support, that is coming in the next PR.
This commit is contained in:
parent
2345ce2b4f
commit
f14be11fc3
@ -2,6 +2,7 @@ module Wasp.Cli.Command.Compile
|
||||
( compileIO,
|
||||
compile,
|
||||
compileIOWithOptions,
|
||||
defaultCompileOptions,
|
||||
)
|
||||
where
|
||||
|
||||
@ -40,14 +41,7 @@ compileIO ::
|
||||
Path' Abs (Dir WaspProjectDir) ->
|
||||
Path' Abs (Dir Wasp.Lib.ProjectRootDir) ->
|
||||
IO (Either String ())
|
||||
compileIO waspProjectDir outDir = compileIOWithOptions options waspProjectDir outDir
|
||||
where
|
||||
options =
|
||||
CompileOptions
|
||||
{ externalCodeDirPath = waspProjectDir </> Common.extCodeDirInWaspProjectDir,
|
||||
isBuild = False,
|
||||
sendMessage = cliSendMessage
|
||||
}
|
||||
compileIO waspProjectDir outDir = compileIOWithOptions (defaultCompileOptions waspProjectDir) waspProjectDir outDir
|
||||
|
||||
compileIOWithOptions ::
|
||||
CompileOptions ->
|
||||
@ -66,3 +60,11 @@ compileIOWithOptions options waspProjectDir outDir = do
|
||||
displayWarnings [] = return ()
|
||||
displayWarnings warnings =
|
||||
cliSendMessage $ Msg.Warning "Your project compiled with warnings" (formatMessages warnings ++ "\n\n")
|
||||
|
||||
defaultCompileOptions :: Path' Abs (Dir WaspProjectDir) -> CompileOptions
|
||||
defaultCompileOptions waspProjectDir =
|
||||
CompileOptions
|
||||
{ externalCodeDirPath = waspProjectDir </> Common.extCodeDirInWaspProjectDir,
|
||||
isBuild = False,
|
||||
sendMessage = cliSendMessage
|
||||
}
|
||||
|
@ -3,42 +3,52 @@ module Wasp.Cli.Command.Deps
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Monad.Except (throwError)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import qualified Wasp.AppSpec.App.Dependency as AS.Dependency
|
||||
import Wasp.Cli.Command (Command)
|
||||
import Wasp.Cli.Command (Command, CommandError (..))
|
||||
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
|
||||
import Wasp.Cli.Command.Compile (defaultCompileOptions)
|
||||
import Wasp.Cli.Terminal (title)
|
||||
import qualified Wasp.Generator.NpmDependencies as N
|
||||
import qualified Wasp.Generator.ServerGenerator as ServerGenerator
|
||||
import qualified Wasp.Generator.WebAppGenerator as WebAppGenerator
|
||||
import Wasp.Lib (analyzeWaspProject)
|
||||
import qualified Wasp.Util.Terminal as Term
|
||||
|
||||
deps :: Command ()
|
||||
deps =
|
||||
liftIO $
|
||||
putStrLn $
|
||||
unlines $
|
||||
deps = do
|
||||
waspProjectDir <- findWaspProjectRootDirFromCwd
|
||||
appSpecOrCompileErrors <- liftIO $ analyzeWaspProject waspProjectDir (defaultCompileOptions waspProjectDir)
|
||||
appSpec <-
|
||||
either
|
||||
(throwError . CommandError "Determining dependencies failed due to a compilation error in your Wasp project" . unwords)
|
||||
return
|
||||
appSpecOrCompileErrors
|
||||
|
||||
liftIO . putStrLn . unlines $
|
||||
[ "",
|
||||
title "Below are listed the dependencies that Wasp uses in your project. You can import and use these directly in the code as if you specified them yourself, but you can't change their versions.",
|
||||
""
|
||||
]
|
||||
++ printDeps
|
||||
"Server dependencies:"
|
||||
( N.waspDependencies ServerGenerator.npmDepsForWasp
|
||||
( N.waspDependencies $ ServerGenerator.npmDepsForWasp appSpec
|
||||
)
|
||||
++ [""]
|
||||
++ printDeps
|
||||
"Server devDependencies:"
|
||||
( N.waspDevDependencies ServerGenerator.npmDepsForWasp
|
||||
( N.waspDevDependencies $ ServerGenerator.npmDepsForWasp appSpec
|
||||
)
|
||||
++ [""]
|
||||
++ printDeps
|
||||
"Webapp dependencies:"
|
||||
( N.waspDependencies WebAppGenerator.npmDepsForWasp
|
||||
( N.waspDependencies $ WebAppGenerator.npmDepsForWasp appSpec
|
||||
)
|
||||
++ [""]
|
||||
++ printDeps
|
||||
"Webapp devDependencies:"
|
||||
( N.waspDevDependencies WebAppGenerator.npmDepsForWasp
|
||||
( N.waspDevDependencies $ WebAppGenerator.npmDepsForWasp appSpec
|
||||
)
|
||||
|
||||
printDeps :: String -> [AS.Dependency.Dependency] -> [String]
|
||||
|
@ -12,6 +12,7 @@ const config = {
|
||||
all: {
|
||||
env,
|
||||
port: parseInt(process.env.PORT) || 3001,
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
{=# isAuthEnabled =}
|
||||
auth: {
|
||||
jwtSecret: undefined
|
||||
|
@ -1,26 +0,0 @@
|
||||
import { sleep } from '../utils.js'
|
||||
|
||||
/**
|
||||
* "Immutable-ish" passthrough job wrapper, mainly to be used for testing.
|
||||
*/
|
||||
class PassthroughJob {
|
||||
constructor(values) {
|
||||
this.perform = () => { }
|
||||
this.delayMs = 0
|
||||
Object.assign(this, values)
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new PassthroughJob({ ...this, delayMs: ms })
|
||||
}
|
||||
|
||||
performAsync(args) {
|
||||
return {
|
||||
result: sleep(this.delayMs).then(() => this.perform(args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function jobFactory(fn) {
|
||||
return new PassthroughJob({ perform: fn })
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
{{={= =}=}}
|
||||
import { jobFactory } from './{= jobFactoryName =}.js'
|
||||
import { createJob } from './{= executorJobRelFP =}'
|
||||
{=& jobPerformFnImportStatement =}
|
||||
|
||||
export const {= jobName =} = jobFactory({= jobPerformFnName =})
|
||||
export const {= jobName =} = await createJob({ jobName: "{= jobName =}", jobFn: {= jobPerformFnName =}, defaultJobOptions: {=& jobPerformOptions =} })
|
||||
|
36
waspc/data/Generator/templates/server/src/jobs/core/Job.js
Normal file
36
waspc/data/Generator/templates/server/src/jobs/core/Job.js
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* This is a definition of a job (think draft or invocable computation), not the running instance itself.
|
||||
* This can be submitted one or more times to be executed by some job executor via the same instance.
|
||||
* Once submitted, you get a SubmittedJob to track it later.
|
||||
*/
|
||||
export class Job {
|
||||
#jobName
|
||||
#executorName
|
||||
|
||||
/**
|
||||
* @param {string} jobName - Job name, which should be unique per executor.
|
||||
* @param {string} executorName - The name of the executor that will run submitted jobs.
|
||||
*/
|
||||
constructor(jobName, executorName) {
|
||||
this.#jobName = jobName
|
||||
this.#executorName = executorName
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#executorName
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
delay(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
async submit(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* This is the result of submitting a Job to some executor.
|
||||
* It can be used by callers to track things, or call executor-specific subclass functionality.
|
||||
*/
|
||||
export class SubmittedJob {
|
||||
#job
|
||||
#jobId
|
||||
|
||||
/**
|
||||
* @param {Job} job - The Job that submitted work to an executor.
|
||||
* @param {string} jobId - A UUID for a submitted job in that executor's ecosystem.
|
||||
*/
|
||||
constructor(job, jobId) {
|
||||
this.#job = job
|
||||
this.#jobId = jobId
|
||||
}
|
||||
|
||||
get jobId() {
|
||||
return this.#jobId
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#job.jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#job.executorName
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import { sleep } from '../../utils.js'
|
||||
import { Job } from './Job.js'
|
||||
import { SubmittedJob } from './SubmittedJob.js'
|
||||
|
||||
export const PASSTHROUGH_EXECUTOR_NAME = Symbol('Passthrough')
|
||||
|
||||
/**
|
||||
* A simple job mainly intended for testing. It will not submit work to any
|
||||
* job executor, but instead will simply invoke the underlying perform function.
|
||||
* It is dependency-free, however.
|
||||
*/
|
||||
class PassthroughJob extends Job {
|
||||
#jobFn
|
||||
#delaySeconds
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - Name of the Job.
|
||||
* @param {fn} jobFn - The Job function to execute.
|
||||
* @param {int} delaySeconds - The number of seconds to delay invoking the Job function.
|
||||
*/
|
||||
constructor(jobName, jobFn, delaySeconds = 0) {
|
||||
super(jobName, PASSTHROUGH_EXECUTOR_NAME)
|
||||
this.#jobFn = jobFn
|
||||
this.#delaySeconds = delaySeconds
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int} delaySeconds - Used to delay the processing of the job by some number of seconds.
|
||||
*/
|
||||
delay(delaySeconds) {
|
||||
return new PassthroughJob(this.jobName, this.#jobFn, delaySeconds)
|
||||
}
|
||||
|
||||
async submit(jobArgs) {
|
||||
sleep(this.#delaySeconds * 1000).then(() => this.#jobFn(jobArgs))
|
||||
// NOTE: Dumb random ID generator, mainly so we don't have to add `uuid`
|
||||
// as a dependency in the server generator for something nobody will likely use.
|
||||
let jobId = (Math.random() + 1).toString(36).substring(7)
|
||||
return new SubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
export function createJob({ jobName, jobFn } = {}) {
|
||||
return new PassthroughJob(jobName, jobFn)
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import PgBoss from 'pg-boss'
|
||||
import config from '../../../config.js'
|
||||
|
||||
export const boss = new PgBoss({ connectionString: config.databaseUrl })
|
||||
|
||||
// Ensure PgBoss can only be started once during a server's lifetime.
|
||||
let hasPgBossBeenStarted = false
|
||||
|
||||
/**
|
||||
* Prepares the target PostgreSQL database and begins job monitoring.
|
||||
* If the required database objects do not exist in the specified database,
|
||||
* `boss.start()` will automatically create them.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#start
|
||||
*
|
||||
* After making this call, we can send PgBoss jobs and they will be persisted and acted upon.
|
||||
* This should only be called once during a server's lifetime.
|
||||
*/
|
||||
export async function startPgBoss() {
|
||||
if (!hasPgBossBeenStarted) {
|
||||
console.log('Starting PgBoss...')
|
||||
|
||||
boss.on('error', error => console.error(error))
|
||||
await boss.start()
|
||||
|
||||
console.log('PgBoss started!')
|
||||
hasPgBossBeenStarted = true
|
||||
}
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
import { boss } from './pgBoss.js'
|
||||
import { Job } from '../Job.js'
|
||||
import { SubmittedJob } from '../SubmittedJob.js'
|
||||
|
||||
export const PG_BOSS_EXECUTOR_NAME = Symbol('PgBoss')
|
||||
|
||||
/**
|
||||
* A PgBoss specific SubmittedJob that adds additional PgBoss functionality.
|
||||
*/
|
||||
class PgBossSubmittedJob extends SubmittedJob {
|
||||
constructor(job, jobId) {
|
||||
super(job, jobId)
|
||||
this.pgBoss = {
|
||||
async cancel() { return boss.cancel(jobId) },
|
||||
async resume() { return boss.resume(jobId) },
|
||||
async details() { return boss.getJobById(jobId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a class repesenting a job that can be submitted to PgBoss.
|
||||
* It is not yet submitted until the caller invokes `submit()` on an instance.
|
||||
* The caller can make as many calls to `submit()` as they wish.
|
||||
*/
|
||||
class PgBossJob extends Job {
|
||||
#defaultJobOptions
|
||||
#startAfter
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - The name of the Job. This is what will show up in the pg-boss DB tables.
|
||||
* @param {object} defaultJobOptions - Default options passed to `boss.send()`.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#sendname-data-options
|
||||
* @param {int | string | date} startAfter - Defers job execution. See `delay()` below for more.
|
||||
*/
|
||||
constructor(jobName, defaultJobOptions, startAfter = undefined) {
|
||||
super(jobName, PG_BOSS_EXECUTOR_NAME)
|
||||
this.#defaultJobOptions = defaultJobOptions
|
||||
this.#startAfter = startAfter
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int | string | date} startAfter - Defers job execution by either:
|
||||
* - int: Seconds to delay starting the job [Default: 0]
|
||||
* - string: Start after a UTC Date time string in 8601 format
|
||||
* - Date: Start after a Date object
|
||||
*/
|
||||
delay(startAfter) {
|
||||
return new PgBossJob(this.jobName, this.#defaultJobOptions, startAfter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the job to PgBoss.
|
||||
* @param {object} jobArgs - The job arguments supplied by the user for their perform callback.
|
||||
* @param {object} jobOptions - PgBoss specific options for `boss.send()`, which can override their defaultJobOptions.
|
||||
*/
|
||||
async submit(jobArgs, jobOptions) {
|
||||
const jobId = await boss.send(this.jobName, jobArgs,
|
||||
{ ...this.#defaultJobOptions, ...(this.#startAfter && { startAfter: this.#startAfter }), ...jobOptions })
|
||||
return new PgBossSubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of PgBossJob and initializes the PgBoss executor by registering this job function.
|
||||
* We expect this to be called once per job name. If called multiple times with the same name and different
|
||||
* functions, we will override the previous calls.
|
||||
* @param {string} jobName - The user-defined job name in their .wasp file.
|
||||
* @param {fn} jobFn - The user-defined async job callback function.
|
||||
* @param {object} defaultJobOptions - PgBoss specific options for boss.send() applied to every submit() invocation,
|
||||
* which can overriden in that call.
|
||||
*/
|
||||
export async function createJob({ jobName, jobFn, defaultJobOptions } = {}) {
|
||||
// As a safety precaution against undefined behavior of registering different
|
||||
// functions for the same job name, remove all registered functions first.
|
||||
await boss.offWork(jobName)
|
||||
|
||||
// This tells pgBoss to run given worker function when job/payload with given job name is submitted.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#work
|
||||
await boss.work(jobName, jobFn)
|
||||
|
||||
return new PgBossJob(jobName, defaultJobOptions)
|
||||
}
|
@ -9,8 +9,15 @@ import config from './config.js'
|
||||
{=& serverSetupJsFnImportStatement =}
|
||||
{=/ doesServerSetupFnExist =}
|
||||
|
||||
{=# isPgBossJobExecutorUsed =}
|
||||
import { startPgBoss } from './jobs/core/pgBoss/pgBoss.js'
|
||||
{=/ isPgBossJobExecutorUsed =}
|
||||
|
||||
const startServer = async () => {
|
||||
{=# isPgBossJobExecutorUsed =}
|
||||
await startPgBoss()
|
||||
{=/ isPgBossJobExecutorUsed =}
|
||||
|
||||
const debugLog = debug('server:server')
|
||||
|
||||
const port = normalizePort(config.port)
|
||||
|
@ -5,6 +5,7 @@ import ShellCommands
|
||||
( appendToWaspFile,
|
||||
cdIntoCurrentProject,
|
||||
createFile,
|
||||
setDbToPSQL,
|
||||
waspCliCompile,
|
||||
waspCliNew,
|
||||
)
|
||||
@ -13,7 +14,10 @@ waspJob :: GoldenTest
|
||||
waspJob = do
|
||||
let entityDecl =
|
||||
" job MySpecialJob { \n\
|
||||
\ perform: import { foo } from \"@ext/jobs/bar.js\" \n\
|
||||
\ executor: PgBoss, \n\
|
||||
\ perform: { \n\
|
||||
\ fn: import { foo } from \"@ext/jobs/bar.js\" \n\
|
||||
\ } \n\
|
||||
\ } \n"
|
||||
|
||||
let jobFile =
|
||||
@ -25,6 +29,7 @@ waspJob = do
|
||||
sequence
|
||||
[ waspCliNew,
|
||||
cdIntoCurrentProject,
|
||||
setDbToPSQL,
|
||||
appendToWaspFile entityDecl,
|
||||
createFile jobFile "./ext/jobs" "bar.js",
|
||||
waspCliCompile
|
||||
|
@ -15,7 +15,11 @@ waspBuild/.wasp/build/server/src/dbClient.js
|
||||
waspBuild/.wasp/build/server/src/ext-src/Main.css
|
||||
waspBuild/.wasp/build/server/src/ext-src/MainPage.js
|
||||
waspBuild/.wasp/build/server/src/ext-src/waspLogo.png
|
||||
waspBuild/.wasp/build/server/src/jobs/PassthroughJobFactory.js
|
||||
waspBuild/.wasp/build/server/src/jobs/core/Job.js
|
||||
waspBuild/.wasp/build/server/src/jobs/core/SubmittedJob.js
|
||||
waspBuild/.wasp/build/server/src/jobs/core/passthroughJob.js
|
||||
waspBuild/.wasp/build/server/src/jobs/core/pgBoss/pgBoss.js
|
||||
waspBuild/.wasp/build/server/src/jobs/core/pgBoss/pgBossJob.js
|
||||
waspBuild/.wasp/build/server/src/routes/index.js
|
||||
waspBuild/.wasp/build/server/src/routes/operations/index.js
|
||||
waspBuild/.wasp/build/server/src/server.js
|
||||
|
@ -67,7 +67,7 @@
|
||||
"file",
|
||||
"server/src/config.js"
|
||||
],
|
||||
"e7ca55aa009ef032eb8fe78f8ca7e8b353d1b283454b985720aa29258277f33d"
|
||||
"beed84b80d5bbb90c6d02781cf03d7b1f7192b1e1eeda01becfefa57aa69dc50"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -114,9 +114,37 @@
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/PassthroughJobFactory.js"
|
||||
"server/src/jobs/core/Job.js"
|
||||
],
|
||||
"404443274a33f1104e41d47174edfe5290bf5219fdf7e730bbdce1ad24a93fda"
|
||||
"e0e5d5e802a29032bfc8426097950722ac0dc7931d08641c1c2b02c262e6cdcc"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/SubmittedJob.js"
|
||||
],
|
||||
"75753277b6bd2c1d2e9ea0e80a71c72c84fa18bb7d61da25d798b3ef247e06bd"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/passthroughJob.js"
|
||||
],
|
||||
"5df690abebd10220346751adcfc157dd66f34a033abe017e34ac4e84287090bf"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBoss.js"
|
||||
],
|
||||
"b6bc8378ba0870623fdf63620ceb60306f45616ef073bb399307784fe10b20c4"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBossJob.js"
|
||||
],
|
||||
"d55605f4699cdba098a7a52f6d4d69e4cd4deabf3234b08634f86fbcf9a31dc8"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -137,7 +165,7 @@
|
||||
"file",
|
||||
"server/src/server.js"
|
||||
],
|
||||
"48b40cb20ce5d1171c9a01e28bfb2a1b0efefbee8cc210a52bf7f67aa8ec585b"
|
||||
"3ee2212932180883fabe36bc22e18e2167a63cdbca8bde2dd595419ae2a34e95"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -11,6 +11,7 @@ const config = {
|
||||
all: {
|
||||
env,
|
||||
port: parseInt(process.env.PORT) || 3001,
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
},
|
||||
development: {
|
||||
},
|
||||
|
@ -1,26 +0,0 @@
|
||||
import { sleep } from '../utils.js'
|
||||
|
||||
/**
|
||||
* "Immutable-ish" passthrough job wrapper, mainly to be used for testing.
|
||||
*/
|
||||
class PassthroughJob {
|
||||
constructor(values) {
|
||||
this.perform = () => { }
|
||||
this.delayMs = 0
|
||||
Object.assign(this, values)
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new PassthroughJob({ ...this, delayMs: ms })
|
||||
}
|
||||
|
||||
performAsync(args) {
|
||||
return {
|
||||
result: sleep(this.delayMs).then(() => this.perform(args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function jobFactory(fn) {
|
||||
return new PassthroughJob({ perform: fn })
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* This is a definition of a job (think draft or invocable computation), not the running instance itself.
|
||||
* This can be submitted one or more times to be executed by some job executor via the same instance.
|
||||
* Once submitted, you get a SubmittedJob to track it later.
|
||||
*/
|
||||
export class Job {
|
||||
#jobName
|
||||
#executorName
|
||||
|
||||
/**
|
||||
* @param {string} jobName - Job name, which should be unique per executor.
|
||||
* @param {string} executorName - The name of the executor that will run submitted jobs.
|
||||
*/
|
||||
constructor(jobName, executorName) {
|
||||
this.#jobName = jobName
|
||||
this.#executorName = executorName
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#executorName
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
delay(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
async submit(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* This is the result of submitting a Job to some executor.
|
||||
* It can be used by callers to track things, or call executor-specific subclass functionality.
|
||||
*/
|
||||
export class SubmittedJob {
|
||||
#job
|
||||
#jobId
|
||||
|
||||
/**
|
||||
* @param {Job} job - The Job that submitted work to an executor.
|
||||
* @param {string} jobId - A UUID for a submitted job in that executor's ecosystem.
|
||||
*/
|
||||
constructor(job, jobId) {
|
||||
this.#job = job
|
||||
this.#jobId = jobId
|
||||
}
|
||||
|
||||
get jobId() {
|
||||
return this.#jobId
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#job.jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#job.executorName
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import { sleep } from '../../utils.js'
|
||||
import { Job } from './Job.js'
|
||||
import { SubmittedJob } from './SubmittedJob.js'
|
||||
|
||||
export const PASSTHROUGH_EXECUTOR_NAME = Symbol('Passthrough')
|
||||
|
||||
/**
|
||||
* A simple job mainly intended for testing. It will not submit work to any
|
||||
* job executor, but instead will simply invoke the underlying perform function.
|
||||
* It is dependency-free, however.
|
||||
*/
|
||||
class PassthroughJob extends Job {
|
||||
#jobFn
|
||||
#delaySeconds
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - Name of the Job.
|
||||
* @param {fn} jobFn - The Job function to execute.
|
||||
* @param {int} delaySeconds - The number of seconds to delay invoking the Job function.
|
||||
*/
|
||||
constructor(jobName, jobFn, delaySeconds = 0) {
|
||||
super(jobName, PASSTHROUGH_EXECUTOR_NAME)
|
||||
this.#jobFn = jobFn
|
||||
this.#delaySeconds = delaySeconds
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int} delaySeconds - Used to delay the processing of the job by some number of seconds.
|
||||
*/
|
||||
delay(delaySeconds) {
|
||||
return new PassthroughJob(this.jobName, this.#jobFn, delaySeconds)
|
||||
}
|
||||
|
||||
async submit(jobArgs) {
|
||||
sleep(this.#delaySeconds * 1000).then(() => this.#jobFn(jobArgs))
|
||||
// NOTE: Dumb random ID generator, mainly so we don't have to add `uuid`
|
||||
// as a dependency in the server generator for something nobody will likely use.
|
||||
let jobId = (Math.random() + 1).toString(36).substring(7)
|
||||
return new SubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
export function createJob({ jobName, jobFn } = {}) {
|
||||
return new PassthroughJob(jobName, jobFn)
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import PgBoss from 'pg-boss'
|
||||
import config from '../../../config.js'
|
||||
|
||||
export const boss = new PgBoss({ connectionString: config.databaseUrl })
|
||||
|
||||
// Ensure PgBoss can only be started once during a server's lifetime.
|
||||
let hasPgBossBeenStarted = false
|
||||
|
||||
/**
|
||||
* Prepares the target PostgreSQL database and begins job monitoring.
|
||||
* If the required database objects do not exist in the specified database,
|
||||
* `boss.start()` will automatically create them.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#start
|
||||
*
|
||||
* After making this call, we can send PgBoss jobs and they will be persisted and acted upon.
|
||||
* This should only be called once during a server's lifetime.
|
||||
*/
|
||||
export async function startPgBoss() {
|
||||
if (!hasPgBossBeenStarted) {
|
||||
console.log('Starting PgBoss...')
|
||||
|
||||
boss.on('error', error => console.error(error))
|
||||
await boss.start()
|
||||
|
||||
console.log('PgBoss started!')
|
||||
hasPgBossBeenStarted = true
|
||||
}
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
import { boss } from './pgBoss.js'
|
||||
import { Job } from '../Job.js'
|
||||
import { SubmittedJob } from '../SubmittedJob.js'
|
||||
|
||||
export const PG_BOSS_EXECUTOR_NAME = Symbol('PgBoss')
|
||||
|
||||
/**
|
||||
* A PgBoss specific SubmittedJob that adds additional PgBoss functionality.
|
||||
*/
|
||||
class PgBossSubmittedJob extends SubmittedJob {
|
||||
constructor(job, jobId) {
|
||||
super(job, jobId)
|
||||
this.pgBoss = {
|
||||
async cancel() { return boss.cancel(jobId) },
|
||||
async resume() { return boss.resume(jobId) },
|
||||
async details() { return boss.getJobById(jobId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a class repesenting a job that can be submitted to PgBoss.
|
||||
* It is not yet submitted until the caller invokes `submit()` on an instance.
|
||||
* The caller can make as many calls to `submit()` as they wish.
|
||||
*/
|
||||
class PgBossJob extends Job {
|
||||
#defaultJobOptions
|
||||
#startAfter
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - The name of the Job. This is what will show up in the pg-boss DB tables.
|
||||
* @param {object} defaultJobOptions - Default options passed to `boss.send()`.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#sendname-data-options
|
||||
* @param {int | string | date} startAfter - Defers job execution. See `delay()` below for more.
|
||||
*/
|
||||
constructor(jobName, defaultJobOptions, startAfter = undefined) {
|
||||
super(jobName, PG_BOSS_EXECUTOR_NAME)
|
||||
this.#defaultJobOptions = defaultJobOptions
|
||||
this.#startAfter = startAfter
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int | string | date} startAfter - Defers job execution by either:
|
||||
* - int: Seconds to delay starting the job [Default: 0]
|
||||
* - string: Start after a UTC Date time string in 8601 format
|
||||
* - Date: Start after a Date object
|
||||
*/
|
||||
delay(startAfter) {
|
||||
return new PgBossJob(this.jobName, this.#defaultJobOptions, startAfter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the job to PgBoss.
|
||||
* @param {object} jobArgs - The job arguments supplied by the user for their perform callback.
|
||||
* @param {object} jobOptions - PgBoss specific options for `boss.send()`, which can override their defaultJobOptions.
|
||||
*/
|
||||
async submit(jobArgs, jobOptions) {
|
||||
const jobId = await boss.send(this.jobName, jobArgs,
|
||||
{ ...this.#defaultJobOptions, ...(this.#startAfter && { startAfter: this.#startAfter }), ...jobOptions })
|
||||
return new PgBossSubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of PgBossJob and initializes the PgBoss executor by registering this job function.
|
||||
* We expect this to be called once per job name. If called multiple times with the same name and different
|
||||
* functions, we will override the previous calls.
|
||||
* @param {string} jobName - The user-defined job name in their .wasp file.
|
||||
* @param {fn} jobFn - The user-defined async job callback function.
|
||||
* @param {object} defaultJobOptions - PgBoss specific options for boss.send() applied to every submit() invocation,
|
||||
* which can overriden in that call.
|
||||
*/
|
||||
export async function createJob({ jobName, jobFn, defaultJobOptions } = {}) {
|
||||
// As a safety precaution against undefined behavior of registering different
|
||||
// functions for the same job name, remove all registered functions first.
|
||||
await boss.offWork(jobName)
|
||||
|
||||
// This tells pgBoss to run given worker function when job/payload with given job name is submitted.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#work
|
||||
await boss.work(jobName, jobFn)
|
||||
|
||||
return new PgBossJob(jobName, defaultJobOptions)
|
||||
}
|
@ -7,6 +7,7 @@ import config from './config.js'
|
||||
|
||||
|
||||
const startServer = async () => {
|
||||
|
||||
const debugLog = debug('server:server')
|
||||
|
||||
const port = normalizePort(config.port)
|
||||
|
@ -15,7 +15,11 @@ waspCompile/.wasp/out/server/src/dbClient.js
|
||||
waspCompile/.wasp/out/server/src/ext-src/Main.css
|
||||
waspCompile/.wasp/out/server/src/ext-src/MainPage.js
|
||||
waspCompile/.wasp/out/server/src/ext-src/waspLogo.png
|
||||
waspCompile/.wasp/out/server/src/jobs/PassthroughJobFactory.js
|
||||
waspCompile/.wasp/out/server/src/jobs/core/Job.js
|
||||
waspCompile/.wasp/out/server/src/jobs/core/SubmittedJob.js
|
||||
waspCompile/.wasp/out/server/src/jobs/core/passthroughJob.js
|
||||
waspCompile/.wasp/out/server/src/jobs/core/pgBoss/pgBoss.js
|
||||
waspCompile/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
|
||||
waspCompile/.wasp/out/server/src/routes/index.js
|
||||
waspCompile/.wasp/out/server/src/routes/operations/index.js
|
||||
waspCompile/.wasp/out/server/src/server.js
|
||||
|
@ -67,7 +67,7 @@
|
||||
"file",
|
||||
"server/src/config.js"
|
||||
],
|
||||
"e7ca55aa009ef032eb8fe78f8ca7e8b353d1b283454b985720aa29258277f33d"
|
||||
"beed84b80d5bbb90c6d02781cf03d7b1f7192b1e1eeda01becfefa57aa69dc50"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -114,9 +114,37 @@
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/PassthroughJobFactory.js"
|
||||
"server/src/jobs/core/Job.js"
|
||||
],
|
||||
"404443274a33f1104e41d47174edfe5290bf5219fdf7e730bbdce1ad24a93fda"
|
||||
"e0e5d5e802a29032bfc8426097950722ac0dc7931d08641c1c2b02c262e6cdcc"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/SubmittedJob.js"
|
||||
],
|
||||
"75753277b6bd2c1d2e9ea0e80a71c72c84fa18bb7d61da25d798b3ef247e06bd"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/passthroughJob.js"
|
||||
],
|
||||
"5df690abebd10220346751adcfc157dd66f34a033abe017e34ac4e84287090bf"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBoss.js"
|
||||
],
|
||||
"b6bc8378ba0870623fdf63620ceb60306f45616ef073bb399307784fe10b20c4"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBossJob.js"
|
||||
],
|
||||
"d55605f4699cdba098a7a52f6d4d69e4cd4deabf3234b08634f86fbcf9a31dc8"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -137,7 +165,7 @@
|
||||
"file",
|
||||
"server/src/server.js"
|
||||
],
|
||||
"48b40cb20ce5d1171c9a01e28bfb2a1b0efefbee8cc210a52bf7f67aa8ec585b"
|
||||
"3ee2212932180883fabe36bc22e18e2167a63cdbca8bde2dd595419ae2a34e95"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -11,6 +11,7 @@ const config = {
|
||||
all: {
|
||||
env,
|
||||
port: parseInt(process.env.PORT) || 3001,
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
},
|
||||
development: {
|
||||
},
|
||||
|
@ -1,26 +0,0 @@
|
||||
import { sleep } from '../utils.js'
|
||||
|
||||
/**
|
||||
* "Immutable-ish" passthrough job wrapper, mainly to be used for testing.
|
||||
*/
|
||||
class PassthroughJob {
|
||||
constructor(values) {
|
||||
this.perform = () => { }
|
||||
this.delayMs = 0
|
||||
Object.assign(this, values)
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new PassthroughJob({ ...this, delayMs: ms })
|
||||
}
|
||||
|
||||
performAsync(args) {
|
||||
return {
|
||||
result: sleep(this.delayMs).then(() => this.perform(args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function jobFactory(fn) {
|
||||
return new PassthroughJob({ perform: fn })
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* This is a definition of a job (think draft or invocable computation), not the running instance itself.
|
||||
* This can be submitted one or more times to be executed by some job executor via the same instance.
|
||||
* Once submitted, you get a SubmittedJob to track it later.
|
||||
*/
|
||||
export class Job {
|
||||
#jobName
|
||||
#executorName
|
||||
|
||||
/**
|
||||
* @param {string} jobName - Job name, which should be unique per executor.
|
||||
* @param {string} executorName - The name of the executor that will run submitted jobs.
|
||||
*/
|
||||
constructor(jobName, executorName) {
|
||||
this.#jobName = jobName
|
||||
this.#executorName = executorName
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#executorName
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
delay(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
async submit(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* This is the result of submitting a Job to some executor.
|
||||
* It can be used by callers to track things, or call executor-specific subclass functionality.
|
||||
*/
|
||||
export class SubmittedJob {
|
||||
#job
|
||||
#jobId
|
||||
|
||||
/**
|
||||
* @param {Job} job - The Job that submitted work to an executor.
|
||||
* @param {string} jobId - A UUID for a submitted job in that executor's ecosystem.
|
||||
*/
|
||||
constructor(job, jobId) {
|
||||
this.#job = job
|
||||
this.#jobId = jobId
|
||||
}
|
||||
|
||||
get jobId() {
|
||||
return this.#jobId
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#job.jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#job.executorName
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import { sleep } from '../../utils.js'
|
||||
import { Job } from './Job.js'
|
||||
import { SubmittedJob } from './SubmittedJob.js'
|
||||
|
||||
export const PASSTHROUGH_EXECUTOR_NAME = Symbol('Passthrough')
|
||||
|
||||
/**
|
||||
* A simple job mainly intended for testing. It will not submit work to any
|
||||
* job executor, but instead will simply invoke the underlying perform function.
|
||||
* It is dependency-free, however.
|
||||
*/
|
||||
class PassthroughJob extends Job {
|
||||
#jobFn
|
||||
#delaySeconds
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - Name of the Job.
|
||||
* @param {fn} jobFn - The Job function to execute.
|
||||
* @param {int} delaySeconds - The number of seconds to delay invoking the Job function.
|
||||
*/
|
||||
constructor(jobName, jobFn, delaySeconds = 0) {
|
||||
super(jobName, PASSTHROUGH_EXECUTOR_NAME)
|
||||
this.#jobFn = jobFn
|
||||
this.#delaySeconds = delaySeconds
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int} delaySeconds - Used to delay the processing of the job by some number of seconds.
|
||||
*/
|
||||
delay(delaySeconds) {
|
||||
return new PassthroughJob(this.jobName, this.#jobFn, delaySeconds)
|
||||
}
|
||||
|
||||
async submit(jobArgs) {
|
||||
sleep(this.#delaySeconds * 1000).then(() => this.#jobFn(jobArgs))
|
||||
// NOTE: Dumb random ID generator, mainly so we don't have to add `uuid`
|
||||
// as a dependency in the server generator for something nobody will likely use.
|
||||
let jobId = (Math.random() + 1).toString(36).substring(7)
|
||||
return new SubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
export function createJob({ jobName, jobFn } = {}) {
|
||||
return new PassthroughJob(jobName, jobFn)
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import PgBoss from 'pg-boss'
|
||||
import config from '../../../config.js'
|
||||
|
||||
export const boss = new PgBoss({ connectionString: config.databaseUrl })
|
||||
|
||||
// Ensure PgBoss can only be started once during a server's lifetime.
|
||||
let hasPgBossBeenStarted = false
|
||||
|
||||
/**
|
||||
* Prepares the target PostgreSQL database and begins job monitoring.
|
||||
* If the required database objects do not exist in the specified database,
|
||||
* `boss.start()` will automatically create them.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#start
|
||||
*
|
||||
* After making this call, we can send PgBoss jobs and they will be persisted and acted upon.
|
||||
* This should only be called once during a server's lifetime.
|
||||
*/
|
||||
export async function startPgBoss() {
|
||||
if (!hasPgBossBeenStarted) {
|
||||
console.log('Starting PgBoss...')
|
||||
|
||||
boss.on('error', error => console.error(error))
|
||||
await boss.start()
|
||||
|
||||
console.log('PgBoss started!')
|
||||
hasPgBossBeenStarted = true
|
||||
}
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
import { boss } from './pgBoss.js'
|
||||
import { Job } from '../Job.js'
|
||||
import { SubmittedJob } from '../SubmittedJob.js'
|
||||
|
||||
export const PG_BOSS_EXECUTOR_NAME = Symbol('PgBoss')
|
||||
|
||||
/**
|
||||
* A PgBoss specific SubmittedJob that adds additional PgBoss functionality.
|
||||
*/
|
||||
class PgBossSubmittedJob extends SubmittedJob {
|
||||
constructor(job, jobId) {
|
||||
super(job, jobId)
|
||||
this.pgBoss = {
|
||||
async cancel() { return boss.cancel(jobId) },
|
||||
async resume() { return boss.resume(jobId) },
|
||||
async details() { return boss.getJobById(jobId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a class repesenting a job that can be submitted to PgBoss.
|
||||
* It is not yet submitted until the caller invokes `submit()` on an instance.
|
||||
* The caller can make as many calls to `submit()` as they wish.
|
||||
*/
|
||||
class PgBossJob extends Job {
|
||||
#defaultJobOptions
|
||||
#startAfter
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - The name of the Job. This is what will show up in the pg-boss DB tables.
|
||||
* @param {object} defaultJobOptions - Default options passed to `boss.send()`.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#sendname-data-options
|
||||
* @param {int | string | date} startAfter - Defers job execution. See `delay()` below for more.
|
||||
*/
|
||||
constructor(jobName, defaultJobOptions, startAfter = undefined) {
|
||||
super(jobName, PG_BOSS_EXECUTOR_NAME)
|
||||
this.#defaultJobOptions = defaultJobOptions
|
||||
this.#startAfter = startAfter
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int | string | date} startAfter - Defers job execution by either:
|
||||
* - int: Seconds to delay starting the job [Default: 0]
|
||||
* - string: Start after a UTC Date time string in 8601 format
|
||||
* - Date: Start after a Date object
|
||||
*/
|
||||
delay(startAfter) {
|
||||
return new PgBossJob(this.jobName, this.#defaultJobOptions, startAfter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the job to PgBoss.
|
||||
* @param {object} jobArgs - The job arguments supplied by the user for their perform callback.
|
||||
* @param {object} jobOptions - PgBoss specific options for `boss.send()`, which can override their defaultJobOptions.
|
||||
*/
|
||||
async submit(jobArgs, jobOptions) {
|
||||
const jobId = await boss.send(this.jobName, jobArgs,
|
||||
{ ...this.#defaultJobOptions, ...(this.#startAfter && { startAfter: this.#startAfter }), ...jobOptions })
|
||||
return new PgBossSubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of PgBossJob and initializes the PgBoss executor by registering this job function.
|
||||
* We expect this to be called once per job name. If called multiple times with the same name and different
|
||||
* functions, we will override the previous calls.
|
||||
* @param {string} jobName - The user-defined job name in their .wasp file.
|
||||
* @param {fn} jobFn - The user-defined async job callback function.
|
||||
* @param {object} defaultJobOptions - PgBoss specific options for boss.send() applied to every submit() invocation,
|
||||
* which can overriden in that call.
|
||||
*/
|
||||
export async function createJob({ jobName, jobFn, defaultJobOptions } = {}) {
|
||||
// As a safety precaution against undefined behavior of registering different
|
||||
// functions for the same job name, remove all registered functions first.
|
||||
await boss.offWork(jobName)
|
||||
|
||||
// This tells pgBoss to run given worker function when job/payload with given job name is submitted.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#work
|
||||
await boss.work(jobName, jobFn)
|
||||
|
||||
return new PgBossJob(jobName, defaultJobOptions)
|
||||
}
|
@ -7,6 +7,7 @@ import config from './config.js'
|
||||
|
||||
|
||||
const startServer = async () => {
|
||||
|
||||
const debugLog = debug('server:server')
|
||||
|
||||
const port = normalizePort(config.port)
|
||||
|
@ -17,7 +17,11 @@ waspJob/.wasp/out/server/src/ext-src/MainPage.js
|
||||
waspJob/.wasp/out/server/src/ext-src/jobs/bar.js
|
||||
waspJob/.wasp/out/server/src/ext-src/waspLogo.png
|
||||
waspJob/.wasp/out/server/src/jobs/MySpecialJob.js
|
||||
waspJob/.wasp/out/server/src/jobs/PassthroughJobFactory.js
|
||||
waspJob/.wasp/out/server/src/jobs/core/Job.js
|
||||
waspJob/.wasp/out/server/src/jobs/core/SubmittedJob.js
|
||||
waspJob/.wasp/out/server/src/jobs/core/passthroughJob.js
|
||||
waspJob/.wasp/out/server/src/jobs/core/pgBoss/pgBoss.js
|
||||
waspJob/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
|
||||
waspJob/.wasp/out/server/src/routes/index.js
|
||||
waspJob/.wasp/out/server/src/routes/operations/index.js
|
||||
waspJob/.wasp/out/server/src/server.js
|
||||
|
@ -18,7 +18,7 @@
|
||||
"file",
|
||||
"db/schema.prisma"
|
||||
],
|
||||
"2cd8e420a90505150d496273ceca091869b33ca4e8bf82c59a3ea678c852d63b"
|
||||
"16c90bcaec8038a1b8bff30b2db2f7876b40c0e2e1b088076491f86f14d172c5"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -53,7 +53,7 @@
|
||||
"file",
|
||||
"server/package.json"
|
||||
],
|
||||
"54d8353286dae688a59458973227cfc1fae57549f49f318ddd46a1abdd716da2"
|
||||
"ed4cedc6f1595457dbcf57380909f11e789d5b86af900009dbc1cfe6afa2bb04"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -67,7 +67,7 @@
|
||||
"file",
|
||||
"server/src/config.js"
|
||||
],
|
||||
"e7ca55aa009ef032eb8fe78f8ca7e8b353d1b283454b985720aa29258277f33d"
|
||||
"beed84b80d5bbb90c6d02781cf03d7b1f7192b1e1eeda01becfefa57aa69dc50"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -123,14 +123,42 @@
|
||||
"file",
|
||||
"server/src/jobs/MySpecialJob.js"
|
||||
],
|
||||
"d4a43343328095083bc9109455c592cb1aaf4295fb37790416002d41fee592a4"
|
||||
"36f01c1d688af5eb7eaaec00c920af81dbf2a3d9f54444d94873306e8d10527f"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/PassthroughJobFactory.js"
|
||||
"server/src/jobs/core/Job.js"
|
||||
],
|
||||
"404443274a33f1104e41d47174edfe5290bf5219fdf7e730bbdce1ad24a93fda"
|
||||
"e0e5d5e802a29032bfc8426097950722ac0dc7931d08641c1c2b02c262e6cdcc"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/SubmittedJob.js"
|
||||
],
|
||||
"75753277b6bd2c1d2e9ea0e80a71c72c84fa18bb7d61da25d798b3ef247e06bd"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/passthroughJob.js"
|
||||
],
|
||||
"5df690abebd10220346751adcfc157dd66f34a033abe017e34ac4e84287090bf"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBoss.js"
|
||||
],
|
||||
"b6bc8378ba0870623fdf63620ceb60306f45616ef073bb399307784fe10b20c4"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBossJob.js"
|
||||
],
|
||||
"d55605f4699cdba098a7a52f6d4d69e4cd4deabf3234b08634f86fbcf9a31dc8"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -151,7 +179,7 @@
|
||||
"file",
|
||||
"server/src/server.js"
|
||||
],
|
||||
"48b40cb20ce5d1171c9a01e28bfb2a1b0efefbee8cc210a52bf7f67aa8ec585b"
|
||||
"6ee8aa871c3340f832d2a7fddac32a29a87683699f654bf6ada91377c26ddb2b"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -1,7 +1,7 @@
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:./dev.db"
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
|
@ -1 +1 @@
|
||||
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.4"},{"name":"cors","version":"^2.8.5"},{"name":"debug","version":"~2.6.9"},{"name":"express","version":"~4.16.1"},{"name":"morgan","version":"~1.9.1"},{"name":"@prisma/client","version":"3.9.1"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"8.2.0"},{"name":"helmet","version":"^4.6.0"}],"devDependencies":[{"name":"nodemon","version":"^2.0.4"},{"name":"standard","version":"^14.3.4"},{"name":"prisma","version":"3.9.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.21.1"},{"name":"lodash","version":"^4.17.15"},{"name":"react","version":"^16.12.0"},{"name":"react-dom","version":"^16.12.0"},{"name":"react-query","version":"^3.34.19"},{"name":"react-router-dom","version":"^5.1.2"},{"name":"react-scripts","version":"4.0.3"},{"name":"uuid","version":"^3.4.0"}],"devDependencies":[]}}
|
||||
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.4"},{"name":"cors","version":"^2.8.5"},{"name":"debug","version":"~2.6.9"},{"name":"express","version":"~4.16.1"},{"name":"morgan","version":"~1.9.1"},{"name":"@prisma/client","version":"3.9.1"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"8.2.0"},{"name":"helmet","version":"^4.6.0"},{"name":"pg-boss","version":"^7.2.1"}],"devDependencies":[{"name":"nodemon","version":"^2.0.4"},{"name":"standard","version":"^14.3.4"},{"name":"prisma","version":"3.9.1"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^0.21.1"},{"name":"lodash","version":"^4.17.15"},{"name":"react","version":"^16.12.0"},{"name":"react-dom","version":"^16.12.0"},{"name":"react-query","version":"^3.34.19"},{"name":"react-router-dom","version":"^5.1.2"},{"name":"react-scripts","version":"4.0.3"},{"name":"uuid","version":"^3.4.0"}],"devDependencies":[]}}
|
@ -9,6 +9,7 @@
|
||||
"helmet": "^4.6.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"morgan": "~1.9.1",
|
||||
"pg-boss": "^7.2.1",
|
||||
"secure-password": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -11,6 +11,7 @@ const config = {
|
||||
all: {
|
||||
env,
|
||||
port: parseInt(process.env.PORT) || 3001,
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
},
|
||||
development: {
|
||||
},
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { jobFactory } from './PassthroughJobFactory.js'
|
||||
import { createJob } from './core/pgBoss/pgBossJob.js'
|
||||
import { foo } from './../ext-src/jobs/bar.js'
|
||||
|
||||
export const MySpecialJob = jobFactory(foo)
|
||||
export const MySpecialJob = await createJob({ jobName: "MySpecialJob", jobFn: foo, defaultJobOptions: {} })
|
||||
|
@ -1,26 +0,0 @@
|
||||
import { sleep } from '../utils.js'
|
||||
|
||||
/**
|
||||
* "Immutable-ish" passthrough job wrapper, mainly to be used for testing.
|
||||
*/
|
||||
class PassthroughJob {
|
||||
constructor(values) {
|
||||
this.perform = () => { }
|
||||
this.delayMs = 0
|
||||
Object.assign(this, values)
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new PassthroughJob({ ...this, delayMs: ms })
|
||||
}
|
||||
|
||||
performAsync(args) {
|
||||
return {
|
||||
result: sleep(this.delayMs).then(() => this.perform(args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function jobFactory(fn) {
|
||||
return new PassthroughJob({ perform: fn })
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* This is a definition of a job (think draft or invocable computation), not the running instance itself.
|
||||
* This can be submitted one or more times to be executed by some job executor via the same instance.
|
||||
* Once submitted, you get a SubmittedJob to track it later.
|
||||
*/
|
||||
export class Job {
|
||||
#jobName
|
||||
#executorName
|
||||
|
||||
/**
|
||||
* @param {string} jobName - Job name, which should be unique per executor.
|
||||
* @param {string} executorName - The name of the executor that will run submitted jobs.
|
||||
*/
|
||||
constructor(jobName, executorName) {
|
||||
this.#jobName = jobName
|
||||
this.#executorName = executorName
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#executorName
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
delay(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
async submit(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* This is the result of submitting a Job to some executor.
|
||||
* It can be used by callers to track things, or call executor-specific subclass functionality.
|
||||
*/
|
||||
export class SubmittedJob {
|
||||
#job
|
||||
#jobId
|
||||
|
||||
/**
|
||||
* @param {Job} job - The Job that submitted work to an executor.
|
||||
* @param {string} jobId - A UUID for a submitted job in that executor's ecosystem.
|
||||
*/
|
||||
constructor(job, jobId) {
|
||||
this.#job = job
|
||||
this.#jobId = jobId
|
||||
}
|
||||
|
||||
get jobId() {
|
||||
return this.#jobId
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#job.jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#job.executorName
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import { sleep } from '../../utils.js'
|
||||
import { Job } from './Job.js'
|
||||
import { SubmittedJob } from './SubmittedJob.js'
|
||||
|
||||
export const PASSTHROUGH_EXECUTOR_NAME = Symbol('Passthrough')
|
||||
|
||||
/**
|
||||
* A simple job mainly intended for testing. It will not submit work to any
|
||||
* job executor, but instead will simply invoke the underlying perform function.
|
||||
* It is dependency-free, however.
|
||||
*/
|
||||
class PassthroughJob extends Job {
|
||||
#jobFn
|
||||
#delaySeconds
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - Name of the Job.
|
||||
* @param {fn} jobFn - The Job function to execute.
|
||||
* @param {int} delaySeconds - The number of seconds to delay invoking the Job function.
|
||||
*/
|
||||
constructor(jobName, jobFn, delaySeconds = 0) {
|
||||
super(jobName, PASSTHROUGH_EXECUTOR_NAME)
|
||||
this.#jobFn = jobFn
|
||||
this.#delaySeconds = delaySeconds
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int} delaySeconds - Used to delay the processing of the job by some number of seconds.
|
||||
*/
|
||||
delay(delaySeconds) {
|
||||
return new PassthroughJob(this.jobName, this.#jobFn, delaySeconds)
|
||||
}
|
||||
|
||||
async submit(jobArgs) {
|
||||
sleep(this.#delaySeconds * 1000).then(() => this.#jobFn(jobArgs))
|
||||
// NOTE: Dumb random ID generator, mainly so we don't have to add `uuid`
|
||||
// as a dependency in the server generator for something nobody will likely use.
|
||||
let jobId = (Math.random() + 1).toString(36).substring(7)
|
||||
return new SubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
export function createJob({ jobName, jobFn } = {}) {
|
||||
return new PassthroughJob(jobName, jobFn)
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import PgBoss from 'pg-boss'
|
||||
import config from '../../../config.js'
|
||||
|
||||
export const boss = new PgBoss({ connectionString: config.databaseUrl })
|
||||
|
||||
// Ensure PgBoss can only be started once during a server's lifetime.
|
||||
let hasPgBossBeenStarted = false
|
||||
|
||||
/**
|
||||
* Prepares the target PostgreSQL database and begins job monitoring.
|
||||
* If the required database objects do not exist in the specified database,
|
||||
* `boss.start()` will automatically create them.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#start
|
||||
*
|
||||
* After making this call, we can send PgBoss jobs and they will be persisted and acted upon.
|
||||
* This should only be called once during a server's lifetime.
|
||||
*/
|
||||
export async function startPgBoss() {
|
||||
if (!hasPgBossBeenStarted) {
|
||||
console.log('Starting PgBoss...')
|
||||
|
||||
boss.on('error', error => console.error(error))
|
||||
await boss.start()
|
||||
|
||||
console.log('PgBoss started!')
|
||||
hasPgBossBeenStarted = true
|
||||
}
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
import { boss } from './pgBoss.js'
|
||||
import { Job } from '../Job.js'
|
||||
import { SubmittedJob } from '../SubmittedJob.js'
|
||||
|
||||
export const PG_BOSS_EXECUTOR_NAME = Symbol('PgBoss')
|
||||
|
||||
/**
|
||||
* A PgBoss specific SubmittedJob that adds additional PgBoss functionality.
|
||||
*/
|
||||
class PgBossSubmittedJob extends SubmittedJob {
|
||||
constructor(job, jobId) {
|
||||
super(job, jobId)
|
||||
this.pgBoss = {
|
||||
async cancel() { return boss.cancel(jobId) },
|
||||
async resume() { return boss.resume(jobId) },
|
||||
async details() { return boss.getJobById(jobId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a class repesenting a job that can be submitted to PgBoss.
|
||||
* It is not yet submitted until the caller invokes `submit()` on an instance.
|
||||
* The caller can make as many calls to `submit()` as they wish.
|
||||
*/
|
||||
class PgBossJob extends Job {
|
||||
#defaultJobOptions
|
||||
#startAfter
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - The name of the Job. This is what will show up in the pg-boss DB tables.
|
||||
* @param {object} defaultJobOptions - Default options passed to `boss.send()`.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#sendname-data-options
|
||||
* @param {int | string | date} startAfter - Defers job execution. See `delay()` below for more.
|
||||
*/
|
||||
constructor(jobName, defaultJobOptions, startAfter = undefined) {
|
||||
super(jobName, PG_BOSS_EXECUTOR_NAME)
|
||||
this.#defaultJobOptions = defaultJobOptions
|
||||
this.#startAfter = startAfter
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int | string | date} startAfter - Defers job execution by either:
|
||||
* - int: Seconds to delay starting the job [Default: 0]
|
||||
* - string: Start after a UTC Date time string in 8601 format
|
||||
* - Date: Start after a Date object
|
||||
*/
|
||||
delay(startAfter) {
|
||||
return new PgBossJob(this.jobName, this.#defaultJobOptions, startAfter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the job to PgBoss.
|
||||
* @param {object} jobArgs - The job arguments supplied by the user for their perform callback.
|
||||
* @param {object} jobOptions - PgBoss specific options for `boss.send()`, which can override their defaultJobOptions.
|
||||
*/
|
||||
async submit(jobArgs, jobOptions) {
|
||||
const jobId = await boss.send(this.jobName, jobArgs,
|
||||
{ ...this.#defaultJobOptions, ...(this.#startAfter && { startAfter: this.#startAfter }), ...jobOptions })
|
||||
return new PgBossSubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of PgBossJob and initializes the PgBoss executor by registering this job function.
|
||||
* We expect this to be called once per job name. If called multiple times with the same name and different
|
||||
* functions, we will override the previous calls.
|
||||
* @param {string} jobName - The user-defined job name in their .wasp file.
|
||||
* @param {fn} jobFn - The user-defined async job callback function.
|
||||
* @param {object} defaultJobOptions - PgBoss specific options for boss.send() applied to every submit() invocation,
|
||||
* which can overriden in that call.
|
||||
*/
|
||||
export async function createJob({ jobName, jobFn, defaultJobOptions } = {}) {
|
||||
// As a safety precaution against undefined behavior of registering different
|
||||
// functions for the same job name, remove all registered functions first.
|
||||
await boss.offWork(jobName)
|
||||
|
||||
// This tells pgBoss to run given worker function when job/payload with given job name is submitted.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#work
|
||||
await boss.work(jobName, jobFn)
|
||||
|
||||
return new PgBossJob(jobName, defaultJobOptions)
|
||||
}
|
@ -5,8 +5,11 @@ import app from './app.js'
|
||||
import config from './config.js'
|
||||
|
||||
|
||||
import { startPgBoss } from './jobs/core/pgBoss/pgBoss.js'
|
||||
|
||||
const startServer = async () => {
|
||||
await startPgBoss()
|
||||
|
||||
const debugLog = debug('server:server')
|
||||
|
||||
const port = normalizePort(config.port)
|
||||
|
@ -1,4 +1,5 @@
|
||||
app waspJob {
|
||||
db: { system: PostgreSQL },
|
||||
title: "waspJob"
|
||||
}
|
||||
|
||||
@ -7,6 +8,9 @@ page MainPage {
|
||||
component: import Main from "@ext/MainPage.js"
|
||||
}
|
||||
job MySpecialJob {
|
||||
perform: import { foo } from "@ext/jobs/bar.js"
|
||||
executor: PgBoss,
|
||||
perform: {
|
||||
fn: import { foo } from "@ext/jobs/bar.js"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,11 @@ waspMigrate/.wasp/out/server/src/dbClient.js
|
||||
waspMigrate/.wasp/out/server/src/ext-src/Main.css
|
||||
waspMigrate/.wasp/out/server/src/ext-src/MainPage.js
|
||||
waspMigrate/.wasp/out/server/src/ext-src/waspLogo.png
|
||||
waspMigrate/.wasp/out/server/src/jobs/PassthroughJobFactory.js
|
||||
waspMigrate/.wasp/out/server/src/jobs/core/Job.js
|
||||
waspMigrate/.wasp/out/server/src/jobs/core/SubmittedJob.js
|
||||
waspMigrate/.wasp/out/server/src/jobs/core/passthroughJob.js
|
||||
waspMigrate/.wasp/out/server/src/jobs/core/pgBoss/pgBoss.js
|
||||
waspMigrate/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.js
|
||||
waspMigrate/.wasp/out/server/src/routes/index.js
|
||||
waspMigrate/.wasp/out/server/src/routes/operations/index.js
|
||||
waspMigrate/.wasp/out/server/src/server.js
|
||||
|
@ -67,7 +67,7 @@
|
||||
"file",
|
||||
"server/src/config.js"
|
||||
],
|
||||
"e7ca55aa009ef032eb8fe78f8ca7e8b353d1b283454b985720aa29258277f33d"
|
||||
"beed84b80d5bbb90c6d02781cf03d7b1f7192b1e1eeda01becfefa57aa69dc50"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -114,9 +114,37 @@
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/PassthroughJobFactory.js"
|
||||
"server/src/jobs/core/Job.js"
|
||||
],
|
||||
"404443274a33f1104e41d47174edfe5290bf5219fdf7e730bbdce1ad24a93fda"
|
||||
"e0e5d5e802a29032bfc8426097950722ac0dc7931d08641c1c2b02c262e6cdcc"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/SubmittedJob.js"
|
||||
],
|
||||
"75753277b6bd2c1d2e9ea0e80a71c72c84fa18bb7d61da25d798b3ef247e06bd"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/passthroughJob.js"
|
||||
],
|
||||
"5df690abebd10220346751adcfc157dd66f34a033abe017e34ac4e84287090bf"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBoss.js"
|
||||
],
|
||||
"b6bc8378ba0870623fdf63620ceb60306f45616ef073bb399307784fe10b20c4"
|
||||
],
|
||||
[
|
||||
[
|
||||
"file",
|
||||
"server/src/jobs/core/pgBoss/pgBossJob.js"
|
||||
],
|
||||
"d55605f4699cdba098a7a52f6d4d69e4cd4deabf3234b08634f86fbcf9a31dc8"
|
||||
],
|
||||
[
|
||||
[
|
||||
@ -137,7 +165,7 @@
|
||||
"file",
|
||||
"server/src/server.js"
|
||||
],
|
||||
"48b40cb20ce5d1171c9a01e28bfb2a1b0efefbee8cc210a52bf7f67aa8ec585b"
|
||||
"3ee2212932180883fabe36bc22e18e2167a63cdbca8bde2dd595419ae2a34e95"
|
||||
],
|
||||
[
|
||||
[
|
||||
|
@ -11,6 +11,7 @@ const config = {
|
||||
all: {
|
||||
env,
|
||||
port: parseInt(process.env.PORT) || 3001,
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
},
|
||||
development: {
|
||||
},
|
||||
|
@ -1,26 +0,0 @@
|
||||
import { sleep } from '../utils.js'
|
||||
|
||||
/**
|
||||
* "Immutable-ish" passthrough job wrapper, mainly to be used for testing.
|
||||
*/
|
||||
class PassthroughJob {
|
||||
constructor(values) {
|
||||
this.perform = () => { }
|
||||
this.delayMs = 0
|
||||
Object.assign(this, values)
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new PassthroughJob({ ...this, delayMs: ms })
|
||||
}
|
||||
|
||||
performAsync(args) {
|
||||
return {
|
||||
result: sleep(this.delayMs).then(() => this.perform(args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function jobFactory(fn) {
|
||||
return new PassthroughJob({ perform: fn })
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* This is a definition of a job (think draft or invocable computation), not the running instance itself.
|
||||
* This can be submitted one or more times to be executed by some job executor via the same instance.
|
||||
* Once submitted, you get a SubmittedJob to track it later.
|
||||
*/
|
||||
export class Job {
|
||||
#jobName
|
||||
#executorName
|
||||
|
||||
/**
|
||||
* @param {string} jobName - Job name, which should be unique per executor.
|
||||
* @param {string} executorName - The name of the executor that will run submitted jobs.
|
||||
*/
|
||||
constructor(jobName, executorName) {
|
||||
this.#jobName = jobName
|
||||
this.#executorName = executorName
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#executorName
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
delay(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
|
||||
// NOTE: Subclasses must implement this method.
|
||||
async submit(...args) {
|
||||
throw new Error('Subclasses must implement this method')
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* This is the result of submitting a Job to some executor.
|
||||
* It can be used by callers to track things, or call executor-specific subclass functionality.
|
||||
*/
|
||||
export class SubmittedJob {
|
||||
#job
|
||||
#jobId
|
||||
|
||||
/**
|
||||
* @param {Job} job - The Job that submitted work to an executor.
|
||||
* @param {string} jobId - A UUID for a submitted job in that executor's ecosystem.
|
||||
*/
|
||||
constructor(job, jobId) {
|
||||
this.#job = job
|
||||
this.#jobId = jobId
|
||||
}
|
||||
|
||||
get jobId() {
|
||||
return this.#jobId
|
||||
}
|
||||
|
||||
get jobName() {
|
||||
return this.#job.jobName
|
||||
}
|
||||
|
||||
get executorName() {
|
||||
return this.#job.executorName
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import { sleep } from '../../utils.js'
|
||||
import { Job } from './Job.js'
|
||||
import { SubmittedJob } from './SubmittedJob.js'
|
||||
|
||||
export const PASSTHROUGH_EXECUTOR_NAME = Symbol('Passthrough')
|
||||
|
||||
/**
|
||||
* A simple job mainly intended for testing. It will not submit work to any
|
||||
* job executor, but instead will simply invoke the underlying perform function.
|
||||
* It is dependency-free, however.
|
||||
*/
|
||||
class PassthroughJob extends Job {
|
||||
#jobFn
|
||||
#delaySeconds
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - Name of the Job.
|
||||
* @param {fn} jobFn - The Job function to execute.
|
||||
* @param {int} delaySeconds - The number of seconds to delay invoking the Job function.
|
||||
*/
|
||||
constructor(jobName, jobFn, delaySeconds = 0) {
|
||||
super(jobName, PASSTHROUGH_EXECUTOR_NAME)
|
||||
this.#jobFn = jobFn
|
||||
this.#delaySeconds = delaySeconds
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int} delaySeconds - Used to delay the processing of the job by some number of seconds.
|
||||
*/
|
||||
delay(delaySeconds) {
|
||||
return new PassthroughJob(this.jobName, this.#jobFn, delaySeconds)
|
||||
}
|
||||
|
||||
async submit(jobArgs) {
|
||||
sleep(this.#delaySeconds * 1000).then(() => this.#jobFn(jobArgs))
|
||||
// NOTE: Dumb random ID generator, mainly so we don't have to add `uuid`
|
||||
// as a dependency in the server generator for something nobody will likely use.
|
||||
let jobId = (Math.random() + 1).toString(36).substring(7)
|
||||
return new SubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
export function createJob({ jobName, jobFn } = {}) {
|
||||
return new PassthroughJob(jobName, jobFn)
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import PgBoss from 'pg-boss'
|
||||
import config from '../../../config.js'
|
||||
|
||||
export const boss = new PgBoss({ connectionString: config.databaseUrl })
|
||||
|
||||
// Ensure PgBoss can only be started once during a server's lifetime.
|
||||
let hasPgBossBeenStarted = false
|
||||
|
||||
/**
|
||||
* Prepares the target PostgreSQL database and begins job monitoring.
|
||||
* If the required database objects do not exist in the specified database,
|
||||
* `boss.start()` will automatically create them.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#start
|
||||
*
|
||||
* After making this call, we can send PgBoss jobs and they will be persisted and acted upon.
|
||||
* This should only be called once during a server's lifetime.
|
||||
*/
|
||||
export async function startPgBoss() {
|
||||
if (!hasPgBossBeenStarted) {
|
||||
console.log('Starting PgBoss...')
|
||||
|
||||
boss.on('error', error => console.error(error))
|
||||
await boss.start()
|
||||
|
||||
console.log('PgBoss started!')
|
||||
hasPgBossBeenStarted = true
|
||||
}
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
import { boss } from './pgBoss.js'
|
||||
import { Job } from '../Job.js'
|
||||
import { SubmittedJob } from '../SubmittedJob.js'
|
||||
|
||||
export const PG_BOSS_EXECUTOR_NAME = Symbol('PgBoss')
|
||||
|
||||
/**
|
||||
* A PgBoss specific SubmittedJob that adds additional PgBoss functionality.
|
||||
*/
|
||||
class PgBossSubmittedJob extends SubmittedJob {
|
||||
constructor(job, jobId) {
|
||||
super(job, jobId)
|
||||
this.pgBoss = {
|
||||
async cancel() { return boss.cancel(jobId) },
|
||||
async resume() { return boss.resume(jobId) },
|
||||
async details() { return boss.getJobById(jobId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a class repesenting a job that can be submitted to PgBoss.
|
||||
* It is not yet submitted until the caller invokes `submit()` on an instance.
|
||||
* The caller can make as many calls to `submit()` as they wish.
|
||||
*/
|
||||
class PgBossJob extends Job {
|
||||
#defaultJobOptions
|
||||
#startAfter
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jobName - The name of the Job. This is what will show up in the pg-boss DB tables.
|
||||
* @param {object} defaultJobOptions - Default options passed to `boss.send()`.
|
||||
* Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#sendname-data-options
|
||||
* @param {int | string | date} startAfter - Defers job execution. See `delay()` below for more.
|
||||
*/
|
||||
constructor(jobName, defaultJobOptions, startAfter = undefined) {
|
||||
super(jobName, PG_BOSS_EXECUTOR_NAME)
|
||||
this.#defaultJobOptions = defaultJobOptions
|
||||
this.#startAfter = startAfter
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {int | string | date} startAfter - Defers job execution by either:
|
||||
* - int: Seconds to delay starting the job [Default: 0]
|
||||
* - string: Start after a UTC Date time string in 8601 format
|
||||
* - Date: Start after a Date object
|
||||
*/
|
||||
delay(startAfter) {
|
||||
return new PgBossJob(this.jobName, this.#defaultJobOptions, startAfter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the job to PgBoss.
|
||||
* @param {object} jobArgs - The job arguments supplied by the user for their perform callback.
|
||||
* @param {object} jobOptions - PgBoss specific options for `boss.send()`, which can override their defaultJobOptions.
|
||||
*/
|
||||
async submit(jobArgs, jobOptions) {
|
||||
const jobId = await boss.send(this.jobName, jobArgs,
|
||||
{ ...this.#defaultJobOptions, ...(this.#startAfter && { startAfter: this.#startAfter }), ...jobOptions })
|
||||
return new PgBossSubmittedJob(this, jobId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of PgBossJob and initializes the PgBoss executor by registering this job function.
|
||||
* We expect this to be called once per job name. If called multiple times with the same name and different
|
||||
* functions, we will override the previous calls.
|
||||
* @param {string} jobName - The user-defined job name in their .wasp file.
|
||||
* @param {fn} jobFn - The user-defined async job callback function.
|
||||
* @param {object} defaultJobOptions - PgBoss specific options for boss.send() applied to every submit() invocation,
|
||||
* which can overriden in that call.
|
||||
*/
|
||||
export async function createJob({ jobName, jobFn, defaultJobOptions } = {}) {
|
||||
// As a safety precaution against undefined behavior of registering different
|
||||
// functions for the same job name, remove all registered functions first.
|
||||
await boss.offWork(jobName)
|
||||
|
||||
// This tells pgBoss to run given worker function when job/payload with given job name is submitted.
|
||||
// Ref: https://github.com/timgit/pg-boss/blob/master/docs/readme.md#work
|
||||
await boss.work(jobName, jobFn)
|
||||
|
||||
return new PgBossJob(jobName, defaultJobOptions)
|
||||
}
|
@ -7,6 +7,7 @@ import config from './config.js'
|
||||
|
||||
|
||||
const startServer = async () => {
|
||||
|
||||
const debugLog = debug('server:server')
|
||||
|
||||
const port = normalizePort(config.port)
|
||||
|
@ -1 +0,0 @@
|
||||
TEST_ENV_VAR="I am test"
|
1
waspc/examples/todoApp/.gitignore
vendored
1
waspc/examples/todoApp/.gitignore
vendored
@ -1 +1,2 @@
|
||||
/.wasp/
|
||||
.env
|
||||
|
@ -3,5 +3,5 @@ import { sleep } from '@wasp/utils.js'
|
||||
export const foo = async (args) => {
|
||||
console.log("Inside Job bar's callback foo: ", args)
|
||||
await sleep(4000)
|
||||
return "I am the Job's result!"
|
||||
return { hello: "world" }
|
||||
}
|
||||
|
@ -10,10 +10,10 @@ const setup = async () => {
|
||||
console.log('Custom server setup done!')
|
||||
|
||||
console.log('Kicking off Job...')
|
||||
// Or: const runningJob = mySpecialJob.delay(1000).performAsync({ something: "here" })
|
||||
const runningJob = mySpecialJob.performAsync({ something: "here" })
|
||||
console.log('Waiting for Job result...')
|
||||
runningJob.result.then(res => { console.log(res) }).finally(() => { console.log("Job done!") })
|
||||
// Or: const submittedJob = await mySpecialJob.delay(10).submit({ something: "here" })
|
||||
const submittedJob = await mySpecialJob.submit({ something: "here" })
|
||||
console.log(submittedJob.jobId, submittedJob.jobName, submittedJob.executorName)
|
||||
console.log("submittedJob.pgBoss.details()", await submittedJob.pgBoss.details())
|
||||
}
|
||||
|
||||
export default setup
|
||||
|
@ -1,18 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Task" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"description" TEXT NOT NULL,
|
||||
"isDone" BOOLEAN NOT NULL DEFAULT false,
|
||||
"userId" INTEGER NOT NULL,
|
||||
FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User.email_unique" ON "User"("email");
|
@ -1,18 +0,0 @@
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Task" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"description" TEXT NOT NULL,
|
||||
"isDone" BOOLEAN NOT NULL DEFAULT false,
|
||||
"userId" INTEGER NOT NULL,
|
||||
CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Task" ("description", "id", "isDone", "userId") SELECT "description", "id", "isDone", "userId" FROM "Task";
|
||||
DROP TABLE "Task";
|
||||
ALTER TABLE "new_Task" RENAME TO "Task";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
-- RedefineIndex
|
||||
DROP INDEX "User.email_unique";
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
@ -0,0 +1,24 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Task" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"isDone" BOOLEAN NOT NULL DEFAULT false,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
provider = "postgresql"
|
2
waspc/examples/todoApp/sample.env
Normal file
2
waspc/examples/todoApp/sample.env
Normal file
@ -0,0 +1,2 @@
|
||||
TEST_ENV_VAR="I am test"
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost/todoapp
|
@ -14,6 +14,9 @@ app todoApp {
|
||||
},
|
||||
server: {
|
||||
setupFn: import setup from "@ext/serverSetup.js"
|
||||
},
|
||||
db: {
|
||||
system: PostgreSQL
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,5 +112,9 @@ action toggleAllTasks {
|
||||
}
|
||||
|
||||
job mySpecialJob {
|
||||
perform: import { foo } from "@ext/jobs/bar.js"
|
||||
executor: PgBoss,
|
||||
perform: {
|
||||
fn: import { foo } from "@ext/jobs/bar.js",
|
||||
options: {=json { "retryLimit": 1 } json=}
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,8 @@ module Wasp.Analyzer.Evaluator.Evaluation.TypedExpr.Combinators
|
||||
where
|
||||
|
||||
import Control.Arrow (left)
|
||||
import qualified Data.Aeson as Aeson
|
||||
import qualified Data.ByteString.Lazy.UTF8 as ByteStringLazyUTF8
|
||||
import Data.List (stripPrefix)
|
||||
import qualified StrongPath as SP
|
||||
import Wasp.Analyzer.Evaluator.Evaluation.Internal (evaluation, evaluation', runEvaluation)
|
||||
@ -176,5 +178,8 @@ extImport = evaluation' . withCtx $ \ctx -> \case
|
||||
-- | An evaluation that expects a "JSON".
|
||||
json :: TypedExprEvaluation AppSpec.JSON.JSON
|
||||
json = evaluation' . withCtx $ \ctx -> \case
|
||||
TypedAST.JSON str -> pure $ AppSpec.JSON.JSON str
|
||||
-- TODO: Consider moving String to Aeson conversion into the Parser, so we have better typed info earlier.
|
||||
TypedAST.JSON str -> either (Left . jsonParseError ctx) (Right . AppSpec.JSON.JSON) (Aeson.eitherDecode $ ByteStringLazyUTF8.fromString str)
|
||||
expr -> Left $ ER.mkEvaluationError ctx $ ER.ExpectedType (T.QuoterType "json") (TypedAST.exprType expr)
|
||||
where
|
||||
jsonParseError ctx errMsg = ER.mkEvaluationError ctx $ ER.ParseError $ ER.EvaluationParseError $ "Unable to parse JSON. Details: " ++ errMsg
|
||||
|
@ -15,7 +15,7 @@ import Wasp.AppSpec.App (App)
|
||||
import Wasp.AppSpec.App.Auth (AuthMethod)
|
||||
import Wasp.AppSpec.App.Db (DbSystem)
|
||||
import Wasp.AppSpec.Entity (Entity)
|
||||
import Wasp.AppSpec.Job (Job)
|
||||
import Wasp.AppSpec.Job (Job, JobExecutor)
|
||||
import Wasp.AppSpec.Page (Page)
|
||||
import Wasp.AppSpec.Query (Query)
|
||||
import Wasp.AppSpec.Route (Route)
|
||||
@ -27,6 +27,7 @@ makeDeclType ''Page
|
||||
makeDeclType ''Route
|
||||
makeDeclType ''Query
|
||||
makeDeclType ''Action
|
||||
makeEnumType ''JobExecutor
|
||||
makeDeclType ''Job
|
||||
|
||||
{- ORMOLU_DISABLE -}
|
||||
@ -43,6 +44,7 @@ stdTypes =
|
||||
TD.addDeclType @Route $
|
||||
TD.addDeclType @Query $
|
||||
TD.addDeclType @Action $
|
||||
TD.addEnumType @JobExecutor $
|
||||
TD.addDeclType @Job $
|
||||
TD.empty
|
||||
{- ORMOLU_ENABLE -}
|
||||
|
@ -2,10 +2,19 @@
|
||||
|
||||
module Wasp.AppSpec.JSON
|
||||
( JSON (..),
|
||||
emptyObject,
|
||||
)
|
||||
where
|
||||
|
||||
import qualified Data.Aeson as Aeson
|
||||
import qualified Data.ByteString.Lazy.UTF8 as ByteStringLazyUTF8
|
||||
import Data.Data (Data)
|
||||
|
||||
newtype JSON = JSON String
|
||||
deriving (Show, Eq, Data)
|
||||
newtype JSON = JSON Aeson.Value
|
||||
deriving (Eq, Data)
|
||||
|
||||
instance Show JSON where
|
||||
show (JSON val) = ByteStringLazyUTF8.toString $ Aeson.encode val
|
||||
|
||||
emptyObject :: JSON
|
||||
emptyObject = JSON $ Aeson.object []
|
||||
|
@ -2,16 +2,35 @@
|
||||
|
||||
module Wasp.AppSpec.Job
|
||||
( Job (..),
|
||||
JobExecutor (..),
|
||||
Perform (..),
|
||||
jobExecutors,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Data (Data)
|
||||
import Wasp.AppSpec.Core.Decl (IsDecl)
|
||||
import Wasp.AppSpec.ExtImport (ExtImport)
|
||||
import Wasp.AppSpec.JSON (JSON (..))
|
||||
|
||||
data Job = Job
|
||||
{ perform :: ExtImport
|
||||
{ executor :: JobExecutor,
|
||||
perform :: Perform
|
||||
}
|
||||
deriving (Show, Eq, Data)
|
||||
|
||||
instance IsDecl Job
|
||||
|
||||
data JobExecutor = Passthrough | PgBoss
|
||||
deriving (Show, Eq, Data, Ord, Enum, Bounded)
|
||||
|
||||
data Perform = Perform
|
||||
{ fn :: ExtImport,
|
||||
options :: Maybe JSON
|
||||
}
|
||||
deriving (Show, Eq, Data)
|
||||
|
||||
instance IsDecl Perform
|
||||
|
||||
jobExecutors :: [JobExecutor]
|
||||
jobExecutors = enumFrom minBound :: [JobExecutor]
|
||||
|
8
waspc/src/Wasp/AppSpec/Util.hs
Normal file
8
waspc/src/Wasp/AppSpec/Util.hs
Normal file
@ -0,0 +1,8 @@
|
||||
module Wasp.AppSpec.Util (isPgBossJobExecutorUsed) where
|
||||
|
||||
import Wasp.AppSpec (AppSpec)
|
||||
import qualified Wasp.AppSpec as AS
|
||||
import qualified Wasp.AppSpec.Job as Job
|
||||
|
||||
isPgBossJobExecutorUsed :: AppSpec -> Bool
|
||||
isPgBossJobExecutorUsed spec = any (\(_, job) -> Job.executor job == Job.PgBoss) (AS.getJobs spec)
|
@ -13,12 +13,15 @@ import Data.Maybe (isJust)
|
||||
import Wasp.AppSpec (AppSpec)
|
||||
import qualified Wasp.AppSpec as AS
|
||||
import Wasp.AppSpec.App (App)
|
||||
import qualified Wasp.AppSpec.App as AS.App
|
||||
import qualified Wasp.AppSpec.App as App
|
||||
import qualified Wasp.AppSpec.App.Auth as Auth
|
||||
import qualified Wasp.AppSpec.App.Db as AS.Db
|
||||
import Wasp.AppSpec.Core.Decl (takeDecls)
|
||||
import qualified Wasp.AppSpec.Entity as Entity
|
||||
import qualified Wasp.AppSpec.Entity.Field as Entity.Field
|
||||
import qualified Wasp.AppSpec.Page as Page
|
||||
import Wasp.AppSpec.Util (isPgBossJobExecutorUsed)
|
||||
|
||||
data ValidationError = GenericValidationError String
|
||||
deriving (Show, Eq)
|
||||
@ -31,7 +34,8 @@ validateAppSpec spec =
|
||||
-- NOTE: We check these only if App exists because they all rely on it existing.
|
||||
concat
|
||||
[ validateAppAuthIsSetIfAnyPageRequiresAuth spec,
|
||||
validateAuthUserEntityHasCorrectFieldsIfEmailAndPasswordAuthIsUsed spec
|
||||
validateAuthUserEntityHasCorrectFieldsIfEmailAndPasswordAuthIsUsed spec,
|
||||
validateDbIsPostgresIfPgBossUsed spec
|
||||
]
|
||||
|
||||
validateExactlyOneAppExists :: AppSpec -> Maybe ValidationError
|
||||
@ -55,6 +59,15 @@ validateAppAuthIsSetIfAnyPageRequiresAuth spec =
|
||||
where
|
||||
anyPageRequiresAuth = any ((== Just True) . Page.authRequired) (snd <$> AS.getPages spec)
|
||||
|
||||
validateDbIsPostgresIfPgBossUsed :: AppSpec -> [ValidationError]
|
||||
validateDbIsPostgresIfPgBossUsed spec =
|
||||
if isPgBossJobExecutorUsed spec && not (isPostgresUsed spec)
|
||||
then
|
||||
[ GenericValidationError
|
||||
"Expected app.db.system to be PostgreSQL since there are jobs with executor set to PgBoss."
|
||||
]
|
||||
else []
|
||||
|
||||
validateAuthUserEntityHasCorrectFieldsIfEmailAndPasswordAuthIsUsed :: AppSpec -> [ValidationError]
|
||||
validateAuthUserEntityHasCorrectFieldsIfEmailAndPasswordAuthIsUsed spec = case App.auth (snd $ getApp spec) of
|
||||
Nothing -> []
|
||||
@ -97,3 +110,7 @@ getApp spec = case takeDecls @App (AS.decls spec) of
|
||||
-- | This function assumes that @AppSpec@ it operates on was validated beforehand (with @validateAppSpec@ function).
|
||||
isAuthEnabled :: AppSpec -> Bool
|
||||
isAuthEnabled spec = isJust (App.auth $ snd $ getApp spec)
|
||||
|
||||
-- | This function assumes that @AppSpec@ it operates on was validated beforehand (with @validateAppSpec@ function).
|
||||
isPostgresUsed :: AppSpec -> Bool
|
||||
isPostgresUsed spec = Just AS.Db.PostgreSQL == (AS.Db.system =<< AS.App.db (snd $ getApp spec))
|
||||
|
@ -43,7 +43,7 @@ import qualified Wasp.Generator.WebAppGenerator.Setup as WebAppSetup
|
||||
-- from the record of what's in package.json.
|
||||
ensureNpmInstall :: AppSpec -> Path' Abs (Dir ProjectRootDir) -> IO ([GeneratorWarning], [GeneratorError])
|
||||
ensureNpmInstall spec dstDir = do
|
||||
let errorOrNpmDepsForFullStack = N.buildNpmDepsForFullStack spec SG.npmDepsForWasp WG.npmDepsForWasp
|
||||
let errorOrNpmDepsForFullStack = N.buildNpmDepsForFullStack spec (SG.npmDepsForWasp spec) (WG.npmDepsForWasp spec)
|
||||
case errorOrNpmDepsForFullStack of
|
||||
Left message -> return ([], [GenericGeneratorError ("npm install failed: " ++ message)])
|
||||
Right npmDepsForFullStack -> do
|
||||
|
@ -32,6 +32,7 @@ import qualified Wasp.AppSpec.App.Auth as AS.App.Auth
|
||||
import qualified Wasp.AppSpec.App.Dependency as AS.Dependency
|
||||
import qualified Wasp.AppSpec.App.Server as AS.App.Server
|
||||
import qualified Wasp.AppSpec.Entity as AS.Entity
|
||||
import Wasp.AppSpec.Util (isPgBossJobExecutorUsed)
|
||||
import Wasp.AppSpec.Valid (getApp, isAuthEnabled)
|
||||
import Wasp.Generator.Common (nodeVersion, nodeVersionBounds, npmVersionBounds, prismaVersionBounds)
|
||||
import Wasp.Generator.ExternalCodeGenerator (genExternalCodeDir)
|
||||
@ -49,7 +50,7 @@ import Wasp.Generator.ServerGenerator.Common
|
||||
import qualified Wasp.Generator.ServerGenerator.Common as C
|
||||
import Wasp.Generator.ServerGenerator.ConfigG (genConfigFile)
|
||||
import qualified Wasp.Generator.ServerGenerator.ExternalCodeGenerator as ServerExternalCodeGenerator
|
||||
import Wasp.Generator.ServerGenerator.JobGenerator (genJobFactories, genJobs)
|
||||
import Wasp.Generator.ServerGenerator.JobGenerator (depsRequiredByJobs, genJobExecutors, genJobs)
|
||||
import Wasp.Generator.ServerGenerator.OperationsG (genOperations)
|
||||
import Wasp.Generator.ServerGenerator.OperationsRoutesG (genOperationsRoutes)
|
||||
import qualified Wasp.SemanticVersion as SV
|
||||
@ -59,7 +60,7 @@ genServer :: AppSpec -> Generator [FileDraft]
|
||||
genServer spec =
|
||||
sequence
|
||||
[ genReadme,
|
||||
genPackageJson spec npmDepsForWasp,
|
||||
genPackageJson spec (npmDepsForWasp spec),
|
||||
genNpmrc,
|
||||
genNvmrc,
|
||||
genGitignore
|
||||
@ -68,7 +69,7 @@ genServer spec =
|
||||
<++> genExternalCodeDir ServerExternalCodeGenerator.generatorStrategy (AS.externalCodeFiles spec)
|
||||
<++> genDotEnv spec
|
||||
<++> genJobs spec
|
||||
<++> genJobFactories
|
||||
<++> genJobExecutors
|
||||
|
||||
genDotEnv :: AppSpec -> Generator [FileDraft]
|
||||
genDotEnv spec = return $
|
||||
@ -107,8 +108,8 @@ genPackageJson spec waspDependencies = do
|
||||
]
|
||||
)
|
||||
|
||||
npmDepsForWasp :: N.NpmDepsForWasp
|
||||
npmDepsForWasp =
|
||||
npmDepsForWasp :: AppSpec -> N.NpmDepsForWasp
|
||||
npmDepsForWasp spec =
|
||||
N.NpmDepsForWasp
|
||||
{ N.waspDependencies =
|
||||
AS.Dependency.fromList
|
||||
@ -122,7 +123,8 @@ npmDepsForWasp =
|
||||
("secure-password", "^4.0.0"),
|
||||
("dotenv", "8.2.0"),
|
||||
("helmet", "^4.6.0")
|
||||
],
|
||||
]
|
||||
++ depsRequiredByJobs spec,
|
||||
N.waspDevDependencies =
|
||||
AS.Dependency.fromList
|
||||
[ ("nodemon", "^2.0.4"),
|
||||
@ -204,7 +206,8 @@ genServerJs spec =
|
||||
object
|
||||
[ "doesServerSetupFnExist" .= isJust maybeSetupJsFunction,
|
||||
"serverSetupJsFnImportStatement" .= fromMaybe "" maybeSetupJsFnImportStmt,
|
||||
"serverSetupJsFnIdentifier" .= fromMaybe "" maybeSetupJsFnImportIdentifier
|
||||
"serverSetupJsFnIdentifier" .= fromMaybe "" maybeSetupJsFnImportIdentifier,
|
||||
"isPgBossJobExecutorUsed" .= isPgBossJobExecutorUsed spec
|
||||
]
|
||||
)
|
||||
where
|
||||
|
@ -1,14 +1,14 @@
|
||||
module Wasp.Generator.ServerGenerator.JobGenerator
|
||||
( genJobs,
|
||||
genJobFactories,
|
||||
genJobExecutors,
|
||||
pgBossVersionBounds,
|
||||
pgBossDependency,
|
||||
depsRequiredByJobs,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Aeson (object, (.=))
|
||||
import Data.Maybe
|
||||
( fromJust,
|
||||
)
|
||||
import qualified GHC.Enum as Enum
|
||||
import Data.Maybe (fromJust, fromMaybe)
|
||||
import StrongPath
|
||||
( Dir,
|
||||
File',
|
||||
@ -20,45 +20,35 @@ import StrongPath
|
||||
reldir,
|
||||
reldirP,
|
||||
relfile,
|
||||
(</>),
|
||||
toFilePath,
|
||||
)
|
||||
import qualified StrongPath as SP
|
||||
import Wasp.AppSpec (AppSpec, getJobs)
|
||||
import Wasp.AppSpec.Job (Job (perform))
|
||||
import qualified Wasp.AppSpec.App.Dependency as AS.Dependency
|
||||
import qualified Wasp.AppSpec.JSON as AS.JSON
|
||||
import Wasp.AppSpec.Job (Job, JobExecutor (Passthrough, PgBoss), jobExecutors)
|
||||
import qualified Wasp.AppSpec.Job as J
|
||||
import Wasp.AppSpec.Util (isPgBossJobExecutorUsed)
|
||||
import Wasp.Generator.ExternalCodeGenerator.Common (GeneratedExternalCodeDir)
|
||||
import Wasp.Generator.FileDraft (FileDraft)
|
||||
import Wasp.Generator.JsImport (getJsImportDetailsForExtFnImport)
|
||||
import Wasp.Generator.Monad (Generator)
|
||||
import Wasp.Generator.ServerGenerator.Common
|
||||
( ServerSrcDir,
|
||||
( ServerRootDir,
|
||||
ServerSrcDir,
|
||||
ServerTemplatesDir,
|
||||
srcDirInServerTemplatesDir,
|
||||
)
|
||||
import qualified Wasp.Generator.ServerGenerator.Common as C
|
||||
|
||||
-- | TODO: Make this not hardcoded!
|
||||
relPosixPathFromJobFileToExtSrcDir :: Path Posix (Rel (Dir ServerSrcDir)) (Dir GeneratedExternalCodeDir)
|
||||
relPosixPathFromJobFileToExtSrcDir = [reldirP|../ext-src|]
|
||||
|
||||
data JobFactory = PassthroughJobFactory
|
||||
deriving (Show, Eq, Ord, Enum, Enum.Bounded)
|
||||
|
||||
jobFactories :: [JobFactory]
|
||||
jobFactories = enumFrom minBound :: [JobFactory]
|
||||
|
||||
-- TODO: In future we will detect what type of JobFactory
|
||||
-- to use based on what the Job is using.
|
||||
jobFactoryForJob :: Job -> JobFactory
|
||||
jobFactoryForJob _ = PassthroughJobFactory
|
||||
|
||||
jobFactoryFilePath :: JobFactory -> Path' (Rel d) File'
|
||||
jobFactoryFilePath PassthroughJobFactory = [relfile|src/jobs/PassthroughJobFactory.js|]
|
||||
|
||||
genJobs :: AppSpec -> Generator [FileDraft]
|
||||
genJobs spec = return $ genJob <$> getJobs spec
|
||||
where
|
||||
tmplFile = C.asTmplFile [relfile|src/jobs/_job.js|]
|
||||
dstFileFromJobName jobName = C.asServerFile $ [reldir|src/jobs/|] </> fromJust (parseRelFile $ jobName ++ ".js")
|
||||
tmplFile = C.asTmplFile $ jobsDirInServerTemplatesDir SP.</> [relfile|_job.js|]
|
||||
dstFileFromJobName jobName = jobsDirInServerRootDir SP.</> fromJust (parseRelFile $ jobName ++ ".js")
|
||||
genJob :: (String, Job) -> FileDraft
|
||||
genJob (jobName, job) =
|
||||
let (jobPerformFnName, jobPerformFnImportStatement) = getJsImportDetailsForExtFnImport relPosixPathFromJobFileToExtSrcDir $ perform job
|
||||
let (jobPerformFnName, jobPerformFnImportStatement) = getJsImportDetailsForExtFnImport relPosixPathFromJobFileToExtSrcDir $ (J.fn . J.perform) job
|
||||
in C.mkTmplFdWithDstAndData
|
||||
tmplFile
|
||||
(dstFileFromJobName jobName)
|
||||
@ -67,16 +57,52 @@ genJobs spec = return $ genJob <$> getJobs spec
|
||||
[ "jobName" .= jobName,
|
||||
"jobPerformFnName" .= jobPerformFnName,
|
||||
"jobPerformFnImportStatement" .= jobPerformFnImportStatement,
|
||||
"jobFactoryName" .= show (jobFactoryForJob job)
|
||||
"jobPerformOptions" .= show (fromMaybe AS.JSON.emptyObject (J.options . J.perform $ job)),
|
||||
"executorJobRelFP" .= toFilePath (executorJobTemplateInJobsDir (J.executor job))
|
||||
]
|
||||
)
|
||||
|
||||
genJobFactories :: Generator [FileDraft]
|
||||
genJobFactories = return $ genJobFactory <$> jobFactories
|
||||
-- | TODO: Make this not hardcoded!
|
||||
relPosixPathFromJobFileToExtSrcDir :: Path Posix (Rel (Dir ServerSrcDir)) (Dir GeneratedExternalCodeDir)
|
||||
relPosixPathFromJobFileToExtSrcDir = [reldirP|../ext-src|]
|
||||
|
||||
genJobExecutors :: Generator [FileDraft]
|
||||
genJobExecutors = return $ jobExecutorFds ++ jobExecutorHelperFds
|
||||
where
|
||||
genJobFactory :: JobFactory -> FileDraft
|
||||
genJobFactory jobFactory =
|
||||
let jobFactoryFp = jobFactoryFilePath jobFactory
|
||||
sourceTemplateFp = C.asTmplFile jobFactoryFp
|
||||
destinationServerFp = C.asServerFile jobFactoryFp
|
||||
in C.mkTmplFdWithDstAndData sourceTemplateFp destinationServerFp Nothing
|
||||
jobExecutorFds :: [FileDraft]
|
||||
jobExecutorFds = genJobExecutor <$> jobExecutors
|
||||
|
||||
genJobExecutor :: JobExecutor -> FileDraft
|
||||
genJobExecutor jobExecutor = C.mkTmplFd $ executorJobTemplateInServerTemplatesDir jobExecutor
|
||||
|
||||
jobExecutorHelperFds :: [FileDraft]
|
||||
jobExecutorHelperFds =
|
||||
[ C.mkTmplFd $ jobsDirInServerTemplatesDir SP.</> [relfile|core/pgBoss/pgBoss.js|],
|
||||
C.mkTmplFd $ jobsDirInServerTemplatesDir SP.</> [relfile|core/Job.js|],
|
||||
C.mkTmplFd $ jobsDirInServerTemplatesDir SP.</> [relfile|core/SubmittedJob.js|]
|
||||
]
|
||||
|
||||
data JobsDir
|
||||
|
||||
jobsDirInServerTemplatesDir :: Path' (Rel ServerTemplatesDir) (Dir JobsDir)
|
||||
jobsDirInServerTemplatesDir = srcDirInServerTemplatesDir SP.</> [reldir|jobs|]
|
||||
|
||||
executorJobTemplateInServerTemplatesDir :: JobExecutor -> Path SP.System (Rel ServerTemplatesDir) File'
|
||||
executorJobTemplateInServerTemplatesDir = (jobsDirInServerTemplatesDir SP.</>) . executorJobTemplateInJobsDir
|
||||
|
||||
executorJobTemplateInJobsDir :: JobExecutor -> Path' (Rel JobsDir) File'
|
||||
executorJobTemplateInJobsDir PgBoss = [relfile|core/pgBoss/pgBossJob.js|]
|
||||
executorJobTemplateInJobsDir Passthrough = [relfile|core/passthroughJob.js|]
|
||||
|
||||
-- Path to destination files are the same as in templates dir.
|
||||
jobsDirInServerRootDir :: Path' (Rel ServerRootDir) (Dir JobsDir)
|
||||
jobsDirInServerRootDir = SP.castRel jobsDirInServerTemplatesDir
|
||||
|
||||
pgBossVersionBounds :: String
|
||||
pgBossVersionBounds = "^7.2.1"
|
||||
|
||||
pgBossDependency :: AS.Dependency.Dependency
|
||||
pgBossDependency = AS.Dependency.make ("pg-boss", pgBossVersionBounds)
|
||||
|
||||
depsRequiredByJobs :: AppSpec -> [AS.Dependency.Dependency]
|
||||
depsRequiredByJobs spec = [pgBossDependency | isPgBossJobExecutorUsed spec]
|
||||
|
@ -41,7 +41,7 @@ genWebApp :: AppSpec -> Generator [FileDraft]
|
||||
genWebApp spec = do
|
||||
sequence
|
||||
[ genReadme,
|
||||
genPackageJson spec npmDepsForWasp,
|
||||
genPackageJson spec (npmDepsForWasp spec),
|
||||
genNpmrc,
|
||||
genNvmrc,
|
||||
genGitignore,
|
||||
@ -88,8 +88,8 @@ genNvmrc =
|
||||
-- We want to specify only the major version, check the comment in `ServerGenerator.hs` for details
|
||||
(Just (object ["nodeVersion" .= show (SV.major nodeVersion)]))
|
||||
|
||||
npmDepsForWasp :: N.NpmDepsForWasp
|
||||
npmDepsForWasp =
|
||||
npmDepsForWasp :: AppSpec -> N.NpmDepsForWasp
|
||||
npmDepsForWasp _spec =
|
||||
N.NpmDepsForWasp
|
||||
{ N.waspDependencies =
|
||||
AS.Dependency.fromList
|
||||
|
@ -3,6 +3,7 @@ module Wasp.Lib
|
||||
Generator.start,
|
||||
ProjectRootDir,
|
||||
findWaspFile,
|
||||
analyzeWaspProject,
|
||||
)
|
||||
where
|
||||
|
||||
@ -33,26 +34,42 @@ compile ::
|
||||
CompileOptions ->
|
||||
IO ([CompileWarning], [CompileError])
|
||||
compile waspDir outDir options = do
|
||||
appSpecOrCompileErrors <- analyzeWaspProject waspDir options
|
||||
case appSpecOrCompileErrors of
|
||||
Left compileErrors -> return ([], compileErrors)
|
||||
Right appSpec ->
|
||||
case ASV.validateAppSpec appSpec of
|
||||
[] -> do
|
||||
(generatorWarnings, generatorErrors) <- Generator.writeWebAppCode appSpec outDir (sendMessage options)
|
||||
return (map show generatorWarnings, map show generatorErrors)
|
||||
validationErrors -> do
|
||||
return ([], map show validationErrors)
|
||||
|
||||
analyzeWaspProject ::
|
||||
Path' Abs (Dir WaspProjectDir) ->
|
||||
CompileOptions ->
|
||||
IO (Either [CompileError] AS.AppSpec)
|
||||
analyzeWaspProject waspDir options = do
|
||||
maybeWaspFilePath <- findWaspFile waspDir
|
||||
case maybeWaspFilePath of
|
||||
Nothing -> return ([], ["Couldn't find a single *.wasp file."])
|
||||
Nothing -> return $ Left ["Couldn't find a single *.wasp file."]
|
||||
Just waspFilePath -> do
|
||||
waspFileContent <- readFile (SP.fromAbsFile waspFilePath)
|
||||
case Analyzer.analyze waspFileContent of
|
||||
Left analyzeError ->
|
||||
return
|
||||
( [],
|
||||
return $
|
||||
Left
|
||||
[ showCompilerErrorForTerminal
|
||||
(waspFilePath, waspFileContent)
|
||||
(getErrorMessageAndCtx analyzeError)
|
||||
]
|
||||
)
|
||||
Right decls -> do
|
||||
externalCodeFiles <-
|
||||
ExternalCode.readFiles (CompileOptions.externalCodeDirPath options)
|
||||
maybeDotEnvFile <- findDotEnvFile waspDir
|
||||
maybeMigrationsDir <- findMigrationsDir waspDir
|
||||
let appSpec =
|
||||
return $
|
||||
Right
|
||||
AS.AppSpec
|
||||
{ AS.decls = decls,
|
||||
AS.externalCodeFiles = externalCodeFiles,
|
||||
@ -61,12 +78,6 @@ compile waspDir outDir options = do
|
||||
AS.dotEnvFile = maybeDotEnvFile,
|
||||
AS.isBuild = CompileOptions.isBuild options
|
||||
}
|
||||
case ASV.validateAppSpec appSpec of
|
||||
[] -> do
|
||||
(generatorWarnings, generatorErrors) <- Generator.writeWebAppCode appSpec outDir (sendMessage options)
|
||||
return (map show generatorWarnings, map show generatorErrors)
|
||||
validationErrors -> do
|
||||
return ([], map show validationErrors)
|
||||
|
||||
findWaspFile :: Path' Abs (Dir WaspProjectDir) -> IO (Maybe (Path' Abs File'))
|
||||
findWaspFile waspDir = do
|
||||
|
@ -6,6 +6,7 @@
|
||||
|
||||
module Analyzer.EvaluatorTest where
|
||||
|
||||
import qualified Data.Aeson as Aeson
|
||||
import Data.Data (Data)
|
||||
import Data.List.Split (splitOn)
|
||||
import Data.Maybe (fromJust)
|
||||
@ -137,6 +138,21 @@ makeDeclType ''Tuples
|
||||
|
||||
--------------------
|
||||
|
||||
-------- Special --------
|
||||
|
||||
data AllJson = AllJson
|
||||
{ objectValue :: JSON,
|
||||
arrayValue :: JSON,
|
||||
stringValue :: JSON,
|
||||
nullValue :: JSON,
|
||||
booleanValue :: JSON
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance IsDecl AllJson
|
||||
|
||||
makeDeclType ''AllJson
|
||||
|
||||
eval :: TD.TypeDefinitions -> [String] -> Either EvaluationError [Decl]
|
||||
eval typeDefs source = evaluate typeDefs . fromRight . typeCheck typeDefs . fromRight . parseStatements $ unlines source
|
||||
|
||||
@ -185,7 +201,7 @@ spec_Evaluator = do
|
||||
let source =
|
||||
[ "special Test {",
|
||||
" imps: [import { field } from \"@ext/main.js\", import main from \"@ext/main.js\"],",
|
||||
" json: {=json \"key\": 1 json=}",
|
||||
" json: {=json { \"key\": 1 } json=}",
|
||||
"}"
|
||||
]
|
||||
|
||||
@ -196,10 +212,28 @@ spec_Evaluator = do
|
||||
[ ExtImport (ExtImportField "field") (fromJust $ SP.parseRelFileP "main.js"),
|
||||
ExtImport (ExtImportModule "main") (fromJust $ SP.parseRelFileP "main.js")
|
||||
]
|
||||
(JSON " \"key\": 1 ")
|
||||
(JSON $ Aeson.object ["key" Aeson..= (1 :: Integer)])
|
||||
)
|
||||
]
|
||||
|
||||
it "Evaluates JSON quoters and they show correctly" $ do
|
||||
let typeDefs = TD.addDeclType @AllJson $ TD.empty
|
||||
let source =
|
||||
[ "allJson Test {",
|
||||
" objectValue: {=json { \"key\": 1 } json=},",
|
||||
" arrayValue: {=json [1, 2, 3] json=},",
|
||||
" stringValue: {=json \"hello\" json=},",
|
||||
" nullValue: {=json null json=},",
|
||||
" booleanValue: {=json false json=},",
|
||||
"}"
|
||||
]
|
||||
let Right [("Test", allJson)] = takeDecls <$> eval typeDefs source
|
||||
show (objectValue allJson) `shouldBe` "{\"key\":1}"
|
||||
show (arrayValue allJson) `shouldBe` "[1,2,3]"
|
||||
show (stringValue allJson) `shouldBe` "\"hello\""
|
||||
show (nullValue allJson) `shouldBe` "null"
|
||||
show (booleanValue allJson) `shouldBe` "false"
|
||||
|
||||
it "Evaluates a declaration with a field that has custom evaluation" $ do
|
||||
let typeDefs = TD.addDeclType @Custom $ TD.empty
|
||||
let decls = eval typeDefs ["custom Test { version: \"1.2.3\" }"]
|
||||
|
@ -238,16 +238,14 @@ spec_ParseStatements = do
|
||||
let source =
|
||||
unlines
|
||||
[ "test JSON {=json",
|
||||
" \"key\": \"value\"",
|
||||
" { \"key\": \"value\" }",
|
||||
"json=}",
|
||||
"test JSON2 {=json",
|
||||
" \"key\": \"value\"",
|
||||
"json=}"
|
||||
"test JSON2 {=json [1, 2, 3] json=}"
|
||||
]
|
||||
let ast =
|
||||
AST
|
||||
[ wctx (1, 1) (3, 6) $ Decl "test" "JSON" $ wctx (1, 11) (3, 6) $ Quoter "json" "\n \"key\": \"value\"\n",
|
||||
wctx (4, 1) (6, 6) $ Decl "test" "JSON2" $ wctx (4, 12) (6, 6) $ Quoter "json" "\n \"key\": \"value\"\n"
|
||||
[ wctx (1, 1) (3, 6) $ Decl "test" "JSON" $ wctx (1, 11) (3, 6) $ Quoter "json" "\n { \"key\": \"value\" }\n",
|
||||
wctx (4, 1) (4, 34) $ Decl "test" "JSON2" $ wctx (4, 12) (4, 34) $ Quoter "json" " [1, 2, 3] "
|
||||
]
|
||||
parseStatements source `shouldBe` Right ast
|
||||
|
||||
|
@ -80,7 +80,10 @@ spec_Analyzer = do
|
||||
"}",
|
||||
"",
|
||||
"job BackgroundJob {",
|
||||
" perform: import { backgroundJob } from \"@ext/jobs/baz.js\",",
|
||||
" executor: PgBoss,",
|
||||
" perform: {",
|
||||
" fn: import { backgroundJob } from \"@ext/jobs/baz.js\"",
|
||||
" }",
|
||||
"}"
|
||||
]
|
||||
|
||||
@ -194,10 +197,15 @@ spec_Analyzer = do
|
||||
let expectedJob =
|
||||
[ ( "BackgroundJob",
|
||||
Job.Job
|
||||
{ Job.perform =
|
||||
{ Job.executor = Job.PgBoss,
|
||||
Job.perform =
|
||||
Job.Perform
|
||||
{ Job.fn =
|
||||
ExtImport
|
||||
(ExtImportField "backgroundJob")
|
||||
(fromJust $ SP.parseRelFileP "jobs/baz.js")
|
||||
(fromJust $ SP.parseRelFileP "jobs/baz.js"),
|
||||
Job.options = Nothing
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
|
@ -176,6 +176,7 @@ library
|
||||
Wasp.AppSpec.Query
|
||||
Wasp.AppSpec.Route
|
||||
Wasp.AppSpec.Valid
|
||||
Wasp.AppSpec.Util
|
||||
Wasp.Common
|
||||
Wasp.CompileOptions
|
||||
Wasp.Data
|
||||
|
Loading…
Reference in New Issue
Block a user