Skip to content

Instantly share code, notes, and snippets.

@evaisse
Created October 15, 2025 14:45
Show Gist options
  • Select an option

  • Save evaisse/fa95b7c063567b56c73f200d8a073ec3 to your computer and use it in GitHub Desktop.

Select an option

Save evaisse/fa95b7c063567b56c73f200d8a073ec3 to your computer and use it in GitHub Desktop.
Specific docs for specific pocketbase Go JS (goja) engine

PocketBase JavaScript (Goja Engine) - Specific Behaviors & Limitations

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.

Table of Contents

  1. Engine Overview
  2. Module Loading
  3. Authentication Patterns
  4. ES5 vs ES6+ Compatibility
  5. Collection and Migration Patterns
  6. Routing and Proxy Configuration
  7. Validation and Type Handling
  8. Error Handling
  9. Development Tooling
  10. Best Practices
  11. Common Pitfalls

Engine Overview

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.

Key Constraints:

  • ⚠️ ES5 only - No ES6+ features
  • ⚠️ No Node.js APIs - No fs, buffer, process, etc.
  • ⚠️ No DOM APIs - No window, document, fetch, etc.
  • ⚠️ Limited runtime APIs - Only basic ES5 standard library

Module Loading

WRONG - Module-level require:

// This FAILS in Goja
var utils = require(`${__hooks}/utils.js`)

routerAdd('POST', '/api/endpoint', function (e) {
  utils.someFunction()
})

CORRECT - Require inside execution blocks:

// This WORKS in Goja
routerAdd('POST', '/api/endpoint', function (e) {
  // Load modules inside the route handler
  var utils = require(`${__hooks}/utils.js`)
  utils.someFunction()
})

Module Path Variables:

  • Use ${__hooks} for hooks directory reference
  • DON'T use: __dirname, process.cwd(), relative paths without ${__hooks}
  • DO use: require(\${__hooks}/utils/module.js`)`

Export Patterns:

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

Authentication Patterns

WRONG - Legacy pattern:

var authRecord = e.get('authRecord')

CORRECT - Official pattern:

var authRecord = e.auth

Additional Authentication Methods:

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

ES5 vs ES6+ Compatibility

ES6+ Features That DON'T Work:

Array Methods:

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

Arrow Functions:

// This FAILS
var callback = item => item.id === targetId

Template Literals:

// This FAILS
var message = `Hello ${name}, you have ${count} items`

Destructuring:

// This FAILS
var { id, name } = user
var [first, second] = array

Let/Const:

// These FAIL
let variable = value
const CONSTANT = value

HTTP Response Methods:

// This FAILS - json() is not a method in Goja
var data = response.json()

// This FAILS - async/await not supported
var data = await response.json()

ES5 Alternatives That WORK:

Array Search:

// ✅ Use traditional for loops
var selectedItem = null
for (var i = 0; i < array.length; i++) {
  if (array[i].id === targetId) {
    selectedItem = array[i]
    break
  }
}

Function Declarations:

// ✅ Use function expressions
var callback = function (item) {
  return item.id === targetId
}

String Concatenation:

// ✅ Use string concatenation
var message = 'Hello ' + name + ', you have ' + count + ' items'

Variable Declarations:

// ✅ Use var only
var variable = value
var CONSTANT = value // Convention: UPPERCASE for constants

HTTP Response Handling:

// ✅ 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...
}

Collection and Migration Patterns

Boolean Field Issues:

// ❌ 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
})

Type Conversion for Boolean Fields:

// ✅ Always explicitly convert to boolean
usageRecord.set('success', Boolean(success))

Collection Relation Issues:

// ❌ 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
})

Dynamic Collection ID Resolution:

// ✅ 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
})

Collection Rules Simplification:

// ❌ 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

Timestamp Fields (created/updated):

// ✅ 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
  })
)

Routing and Proxy Configuration

PocketBase Route Naming:

// ✅ Use /api/ prefix for custom routes
routerAdd('POST', '/api/generate-story', function (e) {
  // Custom endpoint
})

routerAdd('POST', '/api/continue-story', function (e) {
  // Custom endpoint
})

Frontend Proxy Configuration:

// 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/, ''),
    },
}

PocketBase Client Configuration:

// 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-story

Validation and Type Handling

Parameter Validation Pattern:

function 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...
}

Type Conversion Best Practices:

// ✅ 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))

Error Handling

Comprehensive Error Logging:

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
}

API Error Handling Pattern:

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

Development Tooling

TypeScript Integration for JavaScript:

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

ESLint Configuration:

// For Node.js-style modules in utils/
/* eslint-env node */
exports.myFunction = myFunction

Custom Lint Command:

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

Best Practices

1. Module Organization:

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

2. Function Parameter Order:

// ✅ Required parameters first, optional parameters last
function trackApiUsage(
  userId,
  requestType,
  model,
  success,
  prompt,
  response,
  usage
) {
  // TypeScript checking works correctly this way
}

3. Environment Variable Loading:

# Makefile - Load .env before PocketBase commands
test:
    cd backend && set -a && [ -f .env ] && . ./.env && set +a && ./pocketbase migrate

4. Status-Based Validation:

// ✅ Use status fields instead of boolean flags for complex workflows
{
  status: 'draft' | 'active' | 'completed' | 'archived'
  // Instead of: is_active, is_complete, etc.
}

Common Pitfalls

1. Module Loading Timing:

// ❌ 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
})

2. Array Method Assumptions:

// ❌ 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
  }
}

3. Authentication Method Confusion:

// ❌ Old/undocumented way
var authRecord = e.get('authRecord')

// ✅ Official documented way
var authRecord = e.auth

4. Boolean Field Validation:

// ❌ 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))

5. Proxy Configuration Order:

// ❌ 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

6. Number Field Validation:

// ❌ 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))

Testing Your Goja Compatibility

Quick Compatibility Check:

# These should all pass for Goja-compatible code:
./pocketbase lint           # PocketBase internal validation
npm run typecheck          # TypeScript checking
npm run lint               # ESLint validation

Debug Goja Issues:

  1. "Not a function" errors → Usually ES6+ method usage
  2. "Cannot read property" errors → Usually undefined variables or wrong API usage
  3. "Module not found" errors → Wrong require() location or path
  4. "Authentication required" errors → Wrong auth pattern usage
  5. "Cannot be blank" validation errors → Boolean field or type conversion issues

Enhanced Error Tracing with Wrapper Functions

The Problem with Direct Route Handlers:

When you pass functions directly to routerAdd, stack traces don't provide detailed information about where errors occurred within your route handlers.

PROBLEM - Direct function approach:

// This gives poor error tracing
routerAdd(
  'POST',
  '/api/generate-story',
  function (e) {
    // Complex logic here...
    // Error traces are hard to follow
  },
  $apis.requireAuth()
)

SOLUTION - Wrapper pattern for better tracing:

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

Benefits of Wrapper Pattern:

  1. Better error tracing - Stack traces show exact function and line numbers
  2. Module organization - Separate complex logic from route registration
  3. Testability - Functions can be imported and tested independently
  4. Reusability - Functions can be used in multiple contexts
  5. Debugging - Easier to set breakpoints and trace execution

Complete Implementation Example:

// 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 = continueStory

This 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment