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:
Shayne Czyzewski 2022-05-03 14:34:25 -04:00 committed by GitHub
parent 2345ce2b4f
commit f14be11fc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
82 changed files with 1620 additions and 329 deletions

View File

@ -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
}

View File

@ -3,43 +3,53 @@ 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 $
[ "",
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
)
++ [""]
++ printDeps
"Server devDependencies:"
( N.waspDevDependencies ServerGenerator.npmDepsForWasp
)
++ [""]
++ printDeps
"Webapp dependencies:"
( N.waspDependencies WebAppGenerator.npmDepsForWasp
)
++ [""]
++ printDeps
"Webapp devDependencies:"
( N.waspDevDependencies WebAppGenerator.npmDepsForWasp
)
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 appSpec
)
++ [""]
++ printDeps
"Server devDependencies:"
( N.waspDevDependencies $ ServerGenerator.npmDepsForWasp appSpec
)
++ [""]
++ printDeps
"Webapp dependencies:"
( N.waspDependencies $ WebAppGenerator.npmDepsForWasp appSpec
)
++ [""]
++ printDeps
"Webapp devDependencies:"
( N.waspDevDependencies $ WebAppGenerator.npmDepsForWasp appSpec
)
printDeps :: String -> [AS.Dependency.Dependency] -> [String]
printDeps dependenciesTitle dependencies =

View File

@ -12,6 +12,7 @@ const config = {
all: {
env,
port: parseInt(process.env.PORT) || 3001,
databaseUrl: process.env.DATABASE_URL,
{=# isAuthEnabled =}
auth: {
jwtSecret: undefined

View File

@ -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 })
}

View File

@ -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 =} })

View 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')
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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"
],
[
[

View File

@ -11,6 +11,7 @@ const config = {
all: {
env,
port: parseInt(process.env.PORT) || 3001,
databaseUrl: process.env.DATABASE_URL,
},
development: {
},

View File

@ -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 })
}

View 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')
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -7,6 +7,7 @@ import config from './config.js'
const startServer = async () => {
const debugLog = debug('server:server')
const port = normalizePort(config.port)

View File

@ -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

View File

@ -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"
],
[
[

View File

@ -11,6 +11,7 @@ const config = {
all: {
env,
port: parseInt(process.env.PORT) || 3001,
databaseUrl: process.env.DATABASE_URL,
},
development: {
},

View File

@ -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 })
}

View 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')
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -7,6 +7,7 @@ import config from './config.js'
const startServer = async () => {
const debugLog = debug('server:server')
const port = normalizePort(config.port)

View File

@ -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

View File

@ -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"
],
[
[

View File

@ -1,7 +1,7 @@
datasource db {
provider = "sqlite"
url = "file:./dev.db"
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {

View File

@ -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":[]}}

View File

@ -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": {

View File

@ -11,6 +11,7 @@ const config = {
all: {
env,
port: parseInt(process.env.PORT) || 3001,
databaseUrl: process.env.DATABASE_URL,
},
development: {
},

View File

@ -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: {} })

View File

@ -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 })
}

View 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')
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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"
}
}

View File

@ -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

View File

@ -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"
],
[
[

View File

@ -11,6 +11,7 @@ const config = {
all: {
env,
port: parseInt(process.env.PORT) || 3001,
databaseUrl: process.env.DATABASE_URL,
},
development: {
},

View File

@ -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 })
}

View 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')
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -7,6 +7,7 @@ import config from './config.js'
const startServer = async () => {
const debugLog = debug('server:server')
const port = normalizePort(config.port)

View File

@ -1 +0,0 @@
TEST_ENV_VAR="I am test"

View File

@ -1 +1,2 @@
/.wasp/
.env

View File

@ -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" }
}

View File

@ -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

View File

@ -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");

View File

@ -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");

View File

@ -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;

View File

@ -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"

View File

@ -0,0 +1,2 @@
TEST_ENV_VAR="I am test"
DATABASE_URL=postgresql://postgres:postgres@localhost/todoapp

View File

@ -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=}
}
}

View File

@ -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

View File

@ -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 -}

View File

@ -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 []

View File

@ -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]

View 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)

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -3,6 +3,7 @@ module Wasp.Lib
Generator.start,
ProjectRootDir,
findWaspFile,
analyzeWaspProject,
)
where
@ -33,40 +34,50 @@ 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 =
AS.AppSpec
{ AS.decls = decls,
AS.externalCodeFiles = externalCodeFiles,
AS.externalCodeDirPath = CompileOptions.externalCodeDirPath options,
AS.migrationsDir = maybeMigrationsDir,
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)
return $
Right
AS.AppSpec
{ AS.decls = decls,
AS.externalCodeFiles = externalCodeFiles,
AS.externalCodeDirPath = CompileOptions.externalCodeDirPath options,
AS.migrationsDir = maybeMigrationsDir,
AS.dotEnvFile = maybeDotEnvFile,
AS.isBuild = CompileOptions.isBuild options
}
findWaspFile :: Path' Abs (Dir WaspProjectDir) -> IO (Maybe (Path' Abs File'))
findWaspFile waspDir = do

View File

@ -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\" }"]

View File

@ -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

View File

@ -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 =
ExtImport
(ExtImportField "backgroundJob")
(fromJust $ SP.parseRelFileP "jobs/baz.js")
{ Job.executor = Job.PgBoss,
Job.perform =
Job.Perform
{ Job.fn =
ExtImport
(ExtImportField "backgroundJob")
(fromJust $ SP.parseRelFileP "jobs/baz.js"),
Job.options = Nothing
}
}
)
]

View File

@ -176,6 +176,7 @@ library
Wasp.AppSpec.Query
Wasp.AppSpec.Route
Wasp.AppSpec.Valid
Wasp.AppSpec.Util
Wasp.Common
Wasp.CompileOptions
Wasp.Data