This document covers the unique aspects, limitations, and unexpected behaviors when working with PocketBase's embedded JavaScript engine (Goja). These are critical differences from standard Node.js or browser JavaScript environments.
- Engine Overview
- Module Loading
- Authentication Patterns
- ES5 vs ES6+ Compatibility
- Collection and Migration Patterns
- Routing and Proxy Configuration
- Validation and Type Handling
- Error Handling
- Development Tooling
- Best Practices
- Common Pitfalls
PocketBase uses the Goja JavaScript engine, which is NOT Node.js or a browser environment. It's a Go-based ES5-compatible JavaScript runtime with significant limitations.
⚠️ ES5 only - No ES6+ features⚠️ No Node.js APIs - Nofs,buffer,process, etc.⚠️ No DOM APIs - Nowindow,document,fetch, etc.⚠️ Limited runtime APIs - Only basic ES5 standard library
// This FAILS in Goja
var utils = require(`${__hooks}/utils.js`)
routerAdd('POST', '/api/endpoint', function (e) {
utils.someFunction()
})// This WORKS in Goja
routerAdd('POST', '/api/endpoint', function (e) {
// Load modules inside the route handler
var utils = require(`${__hooks}/utils.js`)
utils.someFunction()
})- Use
${__hooks}for hooks directory reference - ❌ DON'T use:
__dirname,process.cwd(), relative paths without${__hooks} - ✅ DO use:
require(\${__hooks}/utils/module.js`)`
// In module file (utils.js)
function myFunction() {
// implementation
}
// ✅ CORRECT export pattern for Goja
exports.myFunction = myFunction
// ❌ WRONG - this may not work reliably
module.exports = { myFunction: myFunction }var authRecord = e.get('authRecord')var authRecord = e.auth// Check if user is guest
var isGuest = !e.auth
// Check superuser status
var isSuperuser = e.hasSuperuserAuth()
// Get authenticated user record
var user = e.auth
if (user) {
var userId = user.id
var userEmail = user.email
}// These FAIL in Goja
var result = array.find(item => item.id === targetId)
var filtered = array.filter(item => item.active)
var mapped = array.map(item => item.name)
var includes = array.includes(value)// This FAILS
var callback = item => item.id === targetId// This FAILS
var message = `Hello ${name}, you have ${count} items`// This FAILS
var { id, name } = user
var [first, second] = array// These FAIL
let variable = value
const CONSTANT = value// This FAILS - json() is not a method in Goja
var data = response.json()
// This FAILS - async/await not supported
var data = await response.json()// ✅ Use traditional for loops
var selectedItem = null
for (var i = 0; i < array.length; i++) {
if (array[i].id === targetId) {
selectedItem = array[i]
break
}
}// ✅ Use function expressions
var callback = function (item) {
return item.id === targetId
}// ✅ Use string concatenation
var message = 'Hello ' + name + ', you have ' + count + ' items'// ✅ Use var only
var variable = value
var CONSTANT = value // Convention: UPPERCASE for constants// ✅ Use response.json as a property (not a method)
var response = $http.send({
url: 'https://api.example.com/data',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
})
if (response.statusCode === 200) {
var responseData = response.json // Property, not method
// Process responseData...
}// ❌ PROBLEM - Required boolean fields cause validation errors
new BoolField({
name: 'success',
required: true // This can cause "cannot be blank" errors
})
// ✅ SOLUTION - Make boolean fields optional
new BoolField({
name: 'success',
required: false // Better for boolean fields
})// ✅ Always explicitly convert to boolean
usageRecord.set('success', Boolean(success))// ❌ PROBLEM - cascadeDelete can cause SQL issues
new RelationField({
name: 'user',
collectionId: 'users',
cascadeDelete: true // Can cause "sql: no rows in result set"
})
// ✅ SOLUTION - Avoid cascadeDelete in complex relations
new RelationField({
name: 'user',
collectionId: 'users',
cascadeDelete: false // Safer approach
})// ✅ Handle cases where collections might not exist yet
var adventuresCollection = app.findCollectionByNameOrId('adventures')
var chaptersCollection = app.findCollectionByNameOrId('chapters')
new RelationField({
name: 'adventure',
collectionId: adventuresCollection ? adventuresCollection.id : '',
required: false
})// ❌ PROBLEM - Complex rules can cause SQL lookup errors
collection.createRule = "user = @request.auth.id && otherField != ''"
// ✅ SOLUTION - Use simple rules or empty rules with hooks
collection.createRule = '' // Handle in hooks instead
collection.updateRule = 'user = @request.auth.id' // Simple rules work better// ✅ ALWAYS add explicit timestamp fields - they're not automatic
// Base collections do NOT automatically include created/updated fields
// Add created field (set on creation only)
collection.fields.add(
new AutodateField({
name: 'created',
onCreate: true,
onUpdate: false
})
)
// Add updated field (set on creation and updates)
collection.fields.add(
new AutodateField({
name: 'updated',
onCreate: true,
onUpdate: true
})
)// ✅ Use /api/ prefix for custom routes
routerAdd('POST', '/api/generate-story', function (e) {
// Custom endpoint
})
routerAdd('POST', '/api/continue-story', function (e) {
// Custom endpoint
})// vite.config.ts - Handle both collections and custom routes
proxy: {
// Custom routes first (more specific)
'/api/generate-story': {
target: 'http://localhost:8090',
changeOrigin: true,
},
'/api/continue-story': {
target: 'http://localhost:8090',
changeOrigin: true,
},
// Collections (remove /api prefix)
'/api': {
target: 'http://localhost:8090',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
}// Frontend - Use root path for PocketBase client
const pb = new PocketBase('/') // Not '/api'
// This allows:
// - Collections: /api/collections/* -> (proxy) -> /collections/*
// - Custom routes: /api/generate-story -> (proxy) -> /api/generate-storyfunction myFunction(userId, requestType, model, success) {
// ✅ Always validate required parameters
if (!userId || typeof userId !== 'string') {
throw new Error('userId is required and must be a non-empty string')
}
if (!requestType) {
throw new Error('requestType is required')
}
if (typeof success !== 'boolean') {
throw new Error('success is required and must be a boolean')
}
// Implementation...
}// ✅ Explicit type conversions for PocketBase fields
record.set('count', Number(count))
record.set('active', Boolean(active))
record.set('text', String(text))
record.set('data', JSON.stringify(data))
// ✅ Safe number field handling with validation
var currentValue = existingRecord.get('depth')
var numValue = Number(currentValue)
var newValue = !isNaN(numValue) && numValue >= 0 ? numValue + 1 : 1
record.set('depth', Number(newValue))try {
// Risky operation
} catch (error) {
// ✅ Log both error and stack trace
console.error('Operation failed:', error)
console.error('Error stack:', error.stack || new Error().stack)
// Handle error appropriately
}var response = $http.send(/* ... */)
if (response.statusCode !== 200) {
console.error('API error:', response.raw)
console.error('API error stack trace:', new Error().stack)
// Track error and continue...
} else {
// ✅ CORRECT - Use response.json as property, not method
var responseData = response.json
// ❌ WRONG - response.json() will cause "Not a function" error
// var responseData = response.json()
}/// <reference path="../pb_data/types.d.ts" />
/**
* Function with full JSDoc typing
* @param {string} userId - Non-nullable user ID (required)
* @param {"generate-story" | "continue-story"} requestType - Enum type
* @param {boolean} success - Boolean parameter
* @param {Object} [optionalParam] - Optional parameter
* @returns {void}
* @throws {Error} When validation fails
*/
function myFunction(userId, requestType, success, optionalParam) {
// Implementation with full type checking
}// For Node.js-style modules in utils/
/* eslint-env node */
exports.myFunction = myFunction// pb_hooks/lint.pb.js - Custom PocketBase lint command
$app.rootCmd.addCommand(
new Command({
use: 'lint',
run: function () {
// Validate all .js files in pb_hooks and pb_migrations
}
})
)pb_hooks/
├── story_generation.pb.js # Main endpoints
├── auth.pb.js # User lifecycle
├── lint.pb.js # Custom commands
└── utils/
├── api_usage.js # Reusable utilities
└── helpers.js # Common functions
// ✅ Required parameters first, optional parameters last
function trackApiUsage(
userId,
requestType,
model,
success,
prompt,
response,
usage
) {
// TypeScript checking works correctly this way
}# Makefile - Load .env before PocketBase commands
test:
cd backend && set -a && [ -f .env ] && . ./.env && set +a && ./pocketbase migrate// ✅ Use status fields instead of boolean flags for complex workflows
{
status: 'draft' | 'active' | 'completed' | 'archived'
// Instead of: is_active, is_complete, etc.
}// ❌ This breaks everything
var utils = require(`${__hooks}/utils.js`) // Top level
// ✅ This works
routerAdd('POST', '/api/endpoint', function (e) {
var utils = require(`${__hooks}/utils.js`) // Inside handler
})// ❌ This fails silently or throws "Not a function"
var item = choices.find(c => c.id === targetId)
// ✅ This always works
var item = null
for (var i = 0; i < choices.length; i++) {
if (choices[i].id === targetId) {
item = choices[i]
break
}
}// ❌ Old/undocumented way
var authRecord = e.get('authRecord')
// ✅ Official documented way
var authRecord = e.auth// ❌ This causes "cannot be blank" errors
new BoolField({ name: 'success', required: true })
// ✅ This works reliably
new BoolField({ name: 'success', required: false })
// With explicit conversion: record.set("success", Boolean(value))// ❌ Wrong order - generic /api catches everything first
'/api': { /* generic handler */ },
'/api/generate-story': { /* specific handler */ }, // Never reached!
// ✅ Correct order - specific routes first
'/api/generate-story': { /* specific handler */ },
'/api/continue-story': { /* specific handler */ },
'/api': { /* generic handler */ }, // Fallback// ❌ This can cause "cannot be blank" errors on number fields
record.set('depth', existingRecord.get('depth') + 1) // May be null + 1 = NaN
// ✅ Always validate and convert number fields
var currentDepth = existingRecord.get('depth')
var currentDepthNum = Number(currentDepth)
var newDepth =
!isNaN(currentDepthNum) && currentDepthNum >= 0 ? currentDepthNum + 1 : 1
record.set('depth', Number(newDepth))# These should all pass for Goja-compatible code:
./pocketbase lint # PocketBase internal validation
npm run typecheck # TypeScript checking
npm run lint # ESLint validation- "Not a function" errors → Usually ES6+ method usage
- "Cannot read property" errors → Usually undefined variables or wrong API usage
- "Module not found" errors → Wrong require() location or path
- "Authentication required" errors → Wrong auth pattern usage
- "Cannot be blank" validation errors → Boolean field or type conversion issues
When you pass functions directly to routerAdd, stack traces don't provide detailed information about where errors occurred within your route handlers.
// This gives poor error tracing
routerAdd(
'POST',
'/api/generate-story',
function (e) {
// Complex logic here...
// Error traces are hard to follow
},
$apis.requireAuth()
)// In main hook file (story_generation.pb.js)
routerAdd(
'POST',
'/api/generate-story',
function (e) {
return require(`${__hooks}/api.js`).generateStory(e)
},
$apis.requireAuth()
)
// In separate module file (api.js)
function generateStory(e) {
// Complex logic here...
// Error traces are much clearer
}
exports.generateStory = generateStory- Better error tracing - Stack traces show exact function and line numbers
- Module organization - Separate complex logic from route registration
- Testability - Functions can be imported and tested independently
- Reusability - Functions can be used in multiple contexts
- Debugging - Easier to set breakpoints and trace execution
// pb_hooks/story_generation.pb.js
routerAdd(
'POST',
'/api/generate-story',
function (e) {
return require(`${__hooks}/api.js`).generateStory(e)
},
$apis.requireAuth()
)
routerAdd(
'POST',
'/api/continue-story',
function (e) {
return require(`${__hooks}/api.js`).continueStory(e)
},
$apis.requireAuth()
)
// pb_hooks/api.js
function generateStory(e) {
// All the complex logic with proper error handling
try {
// Implementation...
} catch (error) {
console.error('Story generation error:', error)
console.error(
'Story generation error stack:',
error.stack || new Error().stack
)
return e.json(500, { error: 'Failed to generate story' })
}
}
exports.generateStory = generateStory
exports.continueStory = continueStoryThis pattern provides exploitable traces and makes debugging much more effective in the PocketBase/Goja environment.
Remember: When in doubt, stick to ES5 patterns, validate everything, and use the official PocketBase documentation patterns. The Goja engine is powerful but requires adapting to its limitations.