This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This is @photostructure/sqlite - a standalone npm package that extracts the Node.js SQLite implementation from Node.js core. The goal is to make Node.js's native SQLite functionality available to all Node.js versions (20+), not just those supporting the built-in node:sqlite module (22.5.0+).
Note: node:sqlite was promoted to Release Candidate (Stability: 1.2) in Node.js v25.7.0. The --experimental-sqlite flag was required on 22.5.0–22.12.x; since 22.13.0 it works without a flag (but prints an ExperimentalWarning until v25.7.0).
- Node.js API Compatible: Exact same interface as Node.js built-in SQLite module
- better-sqlite3 Drop-in Replacement: Goal to provide API compatibility with better-sqlite3 for easy migration
- Synchronous Operations: DatabaseSync and StatementSync classes for blocking database operations
- Full SQLite Feature Set: Includes FTS, JSON functions, math functions, spatial extensions, session support, and URI filename support
- Native Performance: Direct SQLite C library integration without additional overhead
- TypeScript Support: Complete type definitions for all APIs
- Cross-Platform: Support for Windows, macOS, and Linux on x64 and ARM64
✅ Core and Advanced Functionality Complete - As per TODO.md, core SQLite functionality and most advanced features are now working with 495 tests passing.
What Works:
- ✅ Core SQLite operations (CREATE, INSERT, SELECT, UPDATE, DELETE)
- ✅ DatabaseSync and StatementSync classes fully functional
- ✅ Parameter binding and data type handling
- ✅ Error handling and memory management
- ✅ Build system and native addon compilation
- ✅ Package structure and TypeScript setup
- ✅ Automated sync from Node.js source
- ✅ Multi-platform CI/CD with prebuilds
- ✅ Comprehensive test coverage (495 tests passing)
- ✅ User-defined functions with all options
- ✅ Aggregate functions with window function support
- ✅ Statement iterator implementation with full protocol
- ✅ SQLite sessions and changesets
- ✅ Backup functionality
- ✅ Extension loading
node-sqlite/
├── src/
│ ├── index.ts # Main TypeScript interface and exports
│ ├── binding.cpp # Native addon entry point (minimal wrapper)
│ ├── sqlite_impl.{h,cpp} # Main SQLite implementation (ported from Node.js)
│ ├── user_function.{h,cpp} # User-defined function support (new feature)
│ ├── upstream/ # Files synced from Node.js repo
│ │ ├── sqlite.js # Original Node.js JavaScript interface
│ │ ├── node_sqlite.{h,cc} # Node.js C++ SQLite implementation
│ │ ├── sqlite3.{c,h} # SQLite library source (amalgamation)
│ │ └── sqlite.gyp # Original Node.js build config
│ └── shims/ # Node.js internal API compatibility layer
│ ├── base_object.h # BaseObject class implementation
│ ├── node_mem.h # Memory management utilities
│ ├── util.h # Node.js utility functions
│ └── ... # Other Node.js internal headers
├── ../node-addon-api/ # ⚠️ CRITICAL: Official N-API docs (clone from github.com/nodejs/node-addon-api)
├── ../node-addon-examples/ # ⚠️ CRITICAL: Official N-API examples (clone from github.com/nodejs/node-addon-examples)
├── ../better-sqlite3/ # Optional: better-sqlite3 for API reference (clone separately if needed)
├── ../node/ # Optional: Node.js repository for reference (clone separately if needed)
├── scripts/
│ └── sync-from-node.js # Automated sync from Node.js repository
├── test/ # Test suite with comprehensive coverage
│ ├── basic.test.ts # Basic functionality tests
│ ├── database.test.ts # Core database operation tests
│ └── user-functions.test.ts # User-defined function tests
├── binding.gyp # Native build configuration
├── package.json # Package configuration and dependencies
└── TODO.md # Remaining tasks and roadmap
Native Addon Layer (src/binding.cpp, src/sqlite_impl.{h,cpp}):
- Entry point for Node.js addon with minimal wrapper in binding.cpp
- Main implementation in sqlite_impl.cpp (ported from Node.js node_sqlite.cc)
- Full DatabaseSync and StatementSync implementations working
- User-defined functions support in user_function.{h,cpp}
Node.js Compatibility Shims (src/shims/):
- Provides compatibility layer for Node.js internal APIs
- Allows Node.js C++ code to compile in standalone environment
- Key shims: BaseObject, Environment, memory management, error handling
Upstream Sync (src/upstream/):
- Contains exact copies of Node.js SQLite implementation files
- Automatically synced using
scripts/sync-from-node.js - Should not be manually edited (changes will be overwritten)
TypeScript Interface (src/index.ts):
- Public API that matches Node.js SQLite exactly
- Loads native binding and exports typed interfaces
- Handles Symbol.dispose integration
External Reference Implementations (CRITICAL - clone these repositories):
-
../node-addon-api/: Official Node-API C++ wrapper (REQUIRED)- Clone from https://github.com/nodejs/node-addon-api
- PRIMARY resource for all N-API development questions
- Contains API reference, implementation details, and test examples
-
../node-addon-examples/: Official Node-API examples (REQUIRED)- Clone from https://github.com/nodejs/node-addon-examples
- Comprehensive working examples for every N-API pattern
- Use for learning implementation patterns and best practices
-
../better-sqlite3/: Reference for better-sqlite3 API compatibility (optional)- Clone from https://github.com/WiseLibs/better-sqlite3 if needed for API reference
- Used when implementing better-sqlite3 drop-in replacement features
-
../node/: Node.js source repository (optional)- Clone from https://github.com/nodejs/node for reference
- Used for understanding Node.js internals and upstream SQLite implementation
The project maintains two distinct documentation directories:
-
doc/: Manually written documentation files checked into git- Contains architecture documents, API guides, and design notes
- These files are version-controlled and maintained by developers
- Referenced by TypeDoc as additional project documents
-
build/docs/: TypeDoc-generated API documentation (gitignored)- Generated from TypeScript source code and JSDoc comments
- Created by running
npm run docs - Automatically deployed to GitHub Pages via CI/CD
- Not checked into version control (covered by
build/in .gitignore)
This separation ensures that all generated artifacts live under build/ and eliminates confusion between source documentation and generated output.
This project follows consistent naming patterns for npm scripts to improve discoverability and maintainability:
- action: The operation being performed (
build,clean,test,lint,fmt,sync,bench,stress,memory,security) - target: What the action operates on (
native,ts,dist,cjs,esm,api,node) - variant: Optional modifier (
linux,rebuild,full,validate,ci)
| Script | Description |
|---|---|
test |
Quick dev feedback (builds dist, runs Jest without coverage) |
test:all |
Comprehensive tests (builds everything, runs CJS + ESM) |
test:api |
API compatibility tests against node:sqlite |
test:node |
Node.js behavioral compatibility tests |
lint |
Runs TypeScript and eslint (always works) |
lint:full |
Adds optional native linting and API checks |
build:native:linux |
Linux-specific portable GLIBC build |
build:native:rebuild |
Direct node-gyp rebuild |
- Primary commands are short and memorable (
test,lint,build) - Sub-commands use consistent
:suffixnaming for variants - No hidden lifecycle hooks -
pretest/pretestsremoved for clarity - Explicit aggregation -
lintexplicitly lists what it runs, doesn't glob - Platform commands use deep nesting (
build:native:linux) to avoid breaking globs
- If you need to run a command with
sudo, ask the user to run the command. You can't runsudo.
CRITICAL: For ANY N-API or native addon issues, ALWAYS consult these repositories FIRST:
-
../node-addon-api/- The official Node-API C++ wrapper documentation and examples- Clone from https://github.com/nodejs/node-addon-api
- Contains the complete API reference and implementation details
- Use this for understanding N-API types, methods, and patterns
- Check test files for real-world usage examples
-
../node-addon-examples/- Comprehensive examples of N-API patterns- Clone from https://github.com/nodejs/node-addon-examples
- Contains working examples for every N-API feature
- Use this for learning how to implement specific functionality
- Includes examples of callbacks, async work, object wrapping, etc.
When to use these resources:
- ❓ Any N-API type confusion (Napi::Value, Napi::Object, etc.)
- ❓ Memory management questions (References, ObjectWrap, etc.)
- ❓ Callback and function invocation patterns
- ❓ Async worker implementation questions
- ❓ Error handling and exception patterns
- ❓ Build system issues (binding.gyp, node-gyp)
- ❓ Cross-platform compatibility concerns
- ❓ TypedArray, Buffer, or ArrayBuffer handling
- ❓ Basically ANY native addon development question
DO NOT rely on web search, Stack Overflow, or AI suggestions for N-API questions without first checking these authoritative sources. The official examples and documentation are always more reliable and up-to-date.
Success Story: The Alpine/musl SIGSEGV issue (session callback crashes) was solved by consulting node-addon-api/doc/error_handling.md, which documented how primitives are wrapped when thrown from JavaScript. Web searches and AI suggestions led us in the wrong direction initially - the authoritative docs had the answer all along.
- The SQLite implementation has been ported from
src/upstream/node_sqlite.cctosrc/sqlite_impl.cpp - Node.js internal APIs are shimmed in
src/shims/directory - Key shims implemented: BaseObject, Environment, memory tracking, error handling
- V8 APIs have been successfully adapted to N-API equivalents
- Module Loading: Uses
node-gyp-buildinstead ofinternalBinding() - Memory Management: Simplified compared to Node.js internal tracking
- Error Handling: Uses N-API error throwing instead of Node.js internal utilities
- Threading: May need to adapt Node.js's ThreadPoolWork to standard async patterns
- Unit Tests: Basic functionality and API surface
- Integration Tests: Real SQLite operations and data manipulation
- Compatibility Tests: Compare behavior with Node.js built-in SQLite
- Memory Tests: Ensure no leaks in native code
- Platform Tests: Multi-platform and multi-architecture validation
For error handling tests, we prioritize functional behavior over exact error message matching. See doc/internal/testing-philosophy.md for detailed guidelines on:
- When and how to test errors effectively
- Avoiding brittle message matching patterns
- Cross-platform compatibility considerations
- Memory and resource testing approaches
This approach reduces test brittleness while ensuring error handling works correctly across different environments.
- Node.js SQLite is Release Candidate (Stability: 1.2) since v25.7.0 — API is stable but minor changes remain possible
sync-from-node.jsscript maintains file synchronization- Changes should be reviewed for compatibility impact
- Version tracking needed to correlate with Node.js releases
- ✅ Core SQLite functionality - All basic operations working
- ✅ DatabaseSync and StatementSync classes - Fully implemented
- ✅ Parameter binding and data types - All SQLite types supported
- ✅ Error handling and memory management - Proper cleanup implemented
- ✅ Multi-platform CI/CD - GitHub Actions with prebuilds
- ✅ Comprehensive test coverage - 13+ tests covering core functionality
- ✅ Package structure and build system
- ✅ Node.js file synchronization automation
- ✅ TypeScript interfaces and type definitions
- ✅ User-defined functions - Full implementation with all options
- ✅ Aggregate functions - Complete with window function support
- ✅ Statement iterator - Full JavaScript iterator protocol
- ✅ File-based database testing - 11 comprehensive tests
- ❌ Backup functionality - Low priority
- ❌ Extension loading - Advanced feature
- ❌ Automated upstream sync - Nice to have
- Never modify
src/upstream/files - they are auto-synced from Node.js - Main implementation is in
src/sqlite_impl.{h,cpp}(ported from Node.js) - Shims in
src/shims/provide Node.js internal API compatibility - User functions are implemented in
src/user_function.{h,cpp} - Use
../better-sqlite3/for API reference when implementing better-sqlite3 compatibility (clone separately if needed)
- Always run tests before submitting changes:
npm test - Add tests for any new functionality
- Test on multiple platforms via CI/CD when possible
- Focus on compatibility with Node.js SQLite behavior
- Run
npm run lintto check code quality - Native rebuilds use
npm run build:native:rebuild - Multi-platform prebuilds are generated via GitHub Actions
package.jsonversion is managed by the release GitHub Action — do not bump it manually
Follow Conventional Commits format with these guidelines:
Format: <type>(<scope>): <summary>
- First line: Include the file or module being changed, with a summary under 50 characters
- Type: Use conventional commit types (feat, fix, chore, docs, test, refactor, build, ci, perf, style)
- Scope: The primary file, module, or component being changed (e.g.,
gyp,test,precommit,ci) - Summary: Imperative mood, lowercase, no period at end
Additional lines (if needed):
- Keep terse and precise
- Focus on the why, not the what (code diffs show the what)
- Explain motivation, context, or non-obvious implications
- Leave blank line between summary and body
Examples:
refactor(test): simplify control flow in permission test
Replace nested try-catch with try-finally pattern for cleaner
resource management and linear error validation flow.
build(gyp): modernize node-addon-api integration
Use targets property instead of deprecated include/gyp for
alignment with node-addon-api v8.0+ recommendations.
import { DatabaseSync } from "@photostructure/sqlite";
// Create database
const db = new DatabaseSync(":memory:");
// Execute SQL
db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");
// Prepare statements
const insert = db.prepare("INSERT INTO users (name) VALUES (?)");
const select = db.prepare("SELECT * FROM users WHERE id = ?");
// Execute with parameters
const result = insert.run("Alice");
console.log("Inserted ID:", result.lastInsertRowid);
// Query data
const user = select.get(result.lastInsertRowid);
console.log("User:", user);
// Cleanup
db.close();- Never modify
src/upstream/files - they are auto-synced from Node.js - Update shims in
src/shims/when Node.js APIs are missing - Maintain exact API compatibility with Node.js SQLite module
- Add tests for all new functionality
- Update TODO.md when completing tasks
- Run full test suite before submitting changes
CRITICAL: After editing any file that was already staged with git add, you MUST run git add again on that file to stage the new changes. Otherwise, the old version will be committed instead of your edited version.
Always verify staged changes before committing by running git diff --cached or git status -v.
Status: ✅ Fully implemented and tested (21 tests passing)
Aggregate functions presented unique challenges due to N-API constraints in SQLite callback contexts. The implementation is now complete with full type support and comprehensive error handling.
Key Implementation Details:
- N-API Constraints: Cannot use
Napi::Referencefrom SQLite callbacks - must use immediate value conversion - Memory Management: Store only POD types in SQLite's aggregate context, use JSON serialization for complex objects
- Error Handling: Proper early returns after errors (fixed missing return bug from Node.js source)
For detailed implementation history and lessons learned, see: doc/archive/aggregate-function-implementation-summary.md
CRITICAL: N-API's IsBuffer() returns true for ALL ArrayBufferView types, not just Node.js Buffer.
This is because the underlying N-API implementation uses IsArrayBufferView():
Buffer→ IsBuffer: ✓, IsTypedArray: ✓, IsDataView: ✗Uint8Array→ IsBuffer: ✓, IsTypedArray: ✓, IsDataView: ✗DataView→ IsBuffer: ✓, IsTypedArray: ✗, IsDataView: ✓
The Problem: If you check IsBuffer() before IsDataView(), DataView objects get caught by the Buffer check, but Napi::Buffer<uint8_t>::As() returns garbage (length=0, data=null) for DataView.
The Solution: Always check IsDataView() BEFORE IsBuffer() in type-checking chains:
// CORRECT ORDER:
if (param.IsDataView()) {
// Handle DataView specifically
} else if (param.IsBuffer()) {
// Handles Buffer and TypedArray (both work with Buffer cast)
}
// WRONG ORDER (DataView gets mishandled):
if (param.IsBuffer()) {
// DataView matches here but Buffer::As() fails!
} else if (param.IsDataView()) {
// Never reached for DataView
}CRITICAL: C++ exceptions CANNOT safely propagate through C callback boundaries. This causes SIGSEGV, especially on Alpine/musl.
The Problem:
When JavaScript callbacks are invoked from C code (like SQLite's sqlite3changeset_apply):
- JavaScript callback throws →
Napi::Function::Call()converts to C++Napi::Errorexception - Exception propagates: Lambda →
std::function→ C function pointer → C library code - C code + C++ exceptions = undefined behavior = SIGSEGV
The Solution - Always Wrap Callbacks in Try-Catch:
// JavaScript callback stored in lambda, passed to C API
callbacks.myCallback = [&callbacks, env, jsFunc](int arg) -> int {
try {
// Call JavaScript function
Napi::HandleScope scope(env);
Napi::Value result = jsFunc.Call({Napi::Number::New(env, arg)});
// Check for JS exception (normal path)
if (env.IsExceptionPending()) {
Napi::Error err = env.GetAndClearPendingException();
try {
// Extract thrown value (works for primitives and objects)
callbacks.errorMessage = err.Value().ToString().Utf8Value();
} catch (...) {
callbacks.errorMessage = err.Message();
}
callbacks.hasError = true;
return ERROR_CODE;
}
return result.As<Napi::Number>().Int32Value();
} catch (const Napi::Error &e) {
// Catch N-API exceptions (thrown when Call() fails)
try {
callbacks.errorMessage = e.Value().ToString().Utf8Value();
} catch (...) {
callbacks.errorMessage = e.Message();
}
callbacks.hasError = true;
return ERROR_CODE;
} catch (const std::exception &e) {
// Catch other C++ exceptions as fallback
callbacks.errorMessage = std::string("C++ exception: ") + e.what();
callbacks.hasError = true;
return ERROR_CODE;
} catch (...) {
// Catch everything else
callbacks.errorMessage = "Unknown exception in callback";
callbacks.hasError = true;
return ERROR_CODE;
}
};Key Points:
- Catch
Napi::ErrorBEFOREstd::exception-Napi::Errorinherits from both - Use
err.Value().ToString()- Simpler than checking for the special property"4bda9e7e-4913-4dbc-95de-891cbf66598e-errorVal" - Store error messages, re-throw later - Can't throw across C boundary, so store and re-throw after C call returns
- Always return safe error codes - C functions need valid return values, not exceptions
Why This Matters:
- Works on glibc (permissive) but crashes on musl (strict) without this protection
- Applies to any JavaScript callback invoked from C code: SQLite callbacks, libuv callbacks, etc.
- The crash is often delayed/intermittent, making it hard to debug without knowing this pattern
Reference: See src/sqlite_impl.cpp lines 1648-1750 (ApplyChangeset callbacks) for complete working example.
IMPORTANT: The following approaches are NOT valid solutions for async cleanup issues:
// BAD: Arbitrary timeouts in tests
await new Promise((resolve) => setTimeout(resolve, 100));
// BAD: Forcing garbage collection
if (global.gc) {
global.gc();
}
// BAD: Adding setImmediate in afterAll to "fix" hanging tests
afterAll(async () => {
await new Promise((resolve) => setImmediate(resolve));
});Why these are problematic:
- Arbitrary timeouts are race conditions waiting to happen. They might work on fast machines but fail on slower CI runners.
- Forcing GC should never be required for correct behavior. If your code depends on GC for correctness, it has a fundamental design flaw.
- setImmediate/nextTick delays in cleanup hooks don't fix the root cause - they just paper over the real issue.
- These approaches mask the real problem instead of fixing it.
Note: This is different from legitimate uses of timeouts, such as:
- Waiting for time to pass to test timestamp changes
- Rate limiting or throttling tests
- Testing timeout behavior itself
The anti-pattern is using timeouts or GC to "fix" async cleanup issues.
What to do instead:
- Find the actual resource that's keeping the process alive (use
--detectOpenHandles) - Ensure all database connections are properly closed
- Ensure all file handles are closed
- Cancel or await all pending async operations
- Use proper resource management patterns (RAII, try-finally, using statements)
Root Cause: When async operations are not properly cleaned up, Jest may display the "worker process has failed to exit gracefully" warning.
Proper Solutions:
- Use Node.js's built-in AsyncWorker pattern (which BackupJob already uses via
Napi::AsyncProgressWorker) - Ensure all async operations complete before process exit
- Track all async operations and clean them up properly
- Use proper RAII patterns to ensure cleanup happens deterministically
Current Status: The BackupJob implementation correctly uses Napi::AsyncProgressWorker, which is the proper Node.js async pattern. This ensures threads are properly managed and cleaned up.
IMPORTANT: Never use fs.rmSync() or fs.rm() without proper Windows retry logic for directory cleanup in tests.
Problem: On Windows, SQLite database files can remain locked longer than on Unix systems, causing EBUSY errors during cleanup.
Proper Solution: Use fsp.rm() (async) with retry options:
await fsp.rm(tempDir, {
recursive: true,
force: true,
maxRetries: process.platform === "win32" ? 3 : 1,
retryDelay: process.platform === "win32" ? 100 : 0,
});Best Practice: Use the existing test utilities (useTempDir, useTempDirSuite) which already handle Windows-compatible cleanup. Don't manually clean up temp directories - let the test framework handle it.
Based on analysis of CI failures, these guidelines ensure tests are reliable across all platforms and environments.
Pattern: Tests failing with "Exceeded timeout of 10000 ms for a test" especially in memory tests and backup tests.
Root Causes:
- Fixed timeouts don't account for slower CI environments
- Alpine Linux (musl libc) is 2x slower than glibc
- ARM64 emulation on x64 runners is 5x slower
- Windows process forking is 4x slower
- macOS VMs are 4x slower
Solutions:
// DON'T: Use fixed timeouts
test("my test", async () => {
// Test code
}, 10000);
// DO: Use adaptive timeouts
import { getTestTimeout } from "./test-timeout-config.cjs";
test(
"my test",
async () => {
// Test code
},
getTestTimeout(10000),
);
// DO: Use the benchmark harness for memory tests
import { testMemoryBenchmark } from "./benchmark-harness";
testMemoryBenchmark(
"memory test",
() => {
// Test code
},
{ maxTimeoutMs: 60000 },
);Pattern: Multi-process tests expecting DATABASE_LOCKED errors but getting WRITE_SUCCESS.
Root Cause: The timing between establishing a lock and attempting concurrent access varies by platform.
Solutions:
// DON'T: Assume immediate locking
const lockHolder = spawn(nodeCmd, [lockScript]);
const writer = spawn(nodeCmd, [writerScript]);
expect(writer.stdout).toBe("DATABASE_LOCKED"); // May succeed on fast systems!
// DO: Ensure lock is established first
const lockHolder = spawn(nodeCmd, [lockScript]);
// Wait for lock confirmation
await waitForOutput(lockHolder, "LOCK_ACQUIRED");
// Now attempt concurrent access
const writer = spawn(nodeCmd, [writerScript]);
expect(writer.stdout).toBe("DATABASE_LOCKED");Pattern: "Jest did not exit one second after the test run has completed" warnings.
Root Causes:
- Unclosed database connections
- Active async operations
- Console logs after test completion
Solutions:
// DON'T: Leave resources open
test("my test", async () => {
const db = new DatabaseSync("test.db");
// Test code but forget to close
});
// DO: Always clean up resources
test("my test", async () => {
const db = new DatabaseSync("test.db");
try {
// Test code
} finally {
db.close();
}
});
// DO: Use test utilities that handle cleanup
test("my test", async () => {
using tempDir = useTempDir();
const db = tempDir.newDatabase();
// db is automatically closed when tempDir is disposed
});
// DO: Cancel async operations in afterEach/afterAll
let asyncOperation: Promise<void> | null = null;
afterEach(() => {
if (asyncOperation) {
// Cancel or wait for completion
asyncOperation = null;
}
});Pattern: Tests failing only on specific platforms (Alpine ARM64, Windows).
Root Causes:
- Platform timing differences
- File system behavior variations
- Process spawning differences
Solutions:
// DON'T: Assume uniform platform behavior
expect(error.message).toBe("SQLITE_BUSY: database is locked");
// DO: Handle platform variations
expect(error.message).toMatch(/SQLITE_BUSY|database is locked/);
// DO: Use platform-aware utilities
import { getTestTimeout, getTimingMultiplier } from "./test-timeout-config.cjs";
// DO: Add platform-specific retry logic
async function waitForCondition(check: () => boolean, options = {}) {
const { maxAttempts = 10, delay = 100 } = options;
const multiplier = getTimingMultiplier();
for (let i = 0; i < maxAttempts * multiplier; i++) {
if (check()) return true;
await new Promise((resolve) => setTimeout(resolve, delay));
}
return false;
}-
Use Adaptive Timeouts: Always use
getTestTimeout()for Jest timeouts andgetTimingMultiplier()for custom timing logic. -
Explicit Resource Management: Use
usingdeclarations or try/finally blocks to ensure cleanup. -
Wait for Conditions: Don't assume timing - explicitly wait for conditions to be met.
-
Platform-Aware Expectations: Account for platform differences in error messages and behavior.
-
Avoid Console Logs in Async Code: Ensure all logging happens before test completion.
-
Use Test Utilities: Leverage
useTempDir,useTempDirSuite, and other utilities that handle platform differences. -
Benchmark Harness for Performance Tests: Use the adaptive benchmark harness that accounts for environment performance.
Memory tests are particularly prone to flakiness due to:
- GC timing variations
- Platform memory management differences
- CI environment resource constraints
Best Practices:
// Use the memory benchmark harness
testMemoryBenchmark(
"test name",
async () => {
// Operation to test
},
{
maxMemoryGrowthKBPerSecond: 500, // Adjust based on operation
minRSquaredForLeak: 0.5, // Statistical confidence
maxTimeoutMs: 60000, // Generous timeout
},
);Multi-process tests need careful synchronization:
// Use explicit synchronization
const script = `
const db = new DatabaseSync(process.argv[2]);
console.log("READY"); // Signal readiness
// ... test code ...
`;
const proc = spawn(process.execPath, ["-e", script, dbPath]);
await waitForOutput(proc, "READY"); // Wait for process to be ready-
GitHub Actions runners vary significantly:
- Ubuntu: Fast and reliable
- Windows: 4x slower process operations
- macOS: 4x slower in VMs
- Alpine ARM64: 10x slower (2x for musl + 5x for emulation)
-
Resource constraints: CI environments may have limited memory/CPU, affecting timing and performance tests.
-
Parallel test execution: Tests must be isolated and not depend on specific port numbers or global resources.
Based on cross-project analysis and lessons learned from other native Node.js modules, these advanced patterns help eliminate the most stubborn sources of test flakiness:
Problem: ARM64 emulation on x64 GitHub Actions runners is 5-20x slower and can cause unexpected timeouts.
Solution: Auto-detect emulated environments and adjust behavior:
// Detect Alpine Linux emulation environment
const isAlpine = fs.existsSync("/etc/alpine-release");
const isARM64 = process.arch === "arm64";
const isEmulated = isARM64 && process.env.GITHUB_ACTIONS === "true";
// Skip intensive tests on emulated environments
const describeForNative = isEmulated ? describe.skip : describe;
describeForNative("CPU-intensive operations", () => {
// Tests that spawn multiple processes or do heavy computation
});Problem: File system metadata like available space and used space change continuously as other processes run, making exact equality assertions unreliable.
Solution: Focus on type validation and stability patterns:
// DON'T: Test dynamic values for exact equality or ranges
const result1 = db.prepare("PRAGMA freelist_count").get();
const result2 = db.prepare("PRAGMA freelist_count").get();
expect(result1.freelist_count).toBe(result2.freelist_count); // May fail!
expect(result1.freelist_count).toBeGreaterThan(0); // May fail if pages are freed!
// DO: Test for type correctness and structural properties
expect(typeof result.freelist_count).toBe("number");
expect(Number.isInteger(result.freelist_count)).toBe(true);
expect(result.freelist_count).toBeGreaterThanOrEqual(0);
// DO: Test static/stable properties for exact equality
expect(result.page_size).toBe(4096); // Page size doesn't change
expect(result.application_id).toBe(expectedId); // Application ID is stableProblem: Race conditions and resource leaks in concurrent worker operations can cause Jest to hang.
Solution: Implement proper worker lifecycle management:
// Track all active workers for cleanup
const activeWorkers = new Set<Worker>();
afterEach(async () => {
// Terminate all workers with timeout
const terminations = Array.from(activeWorkers).map((worker) =>
Promise.race([
new Promise<void>((resolve) => {
worker.terminate().then(() => resolve());
}),
new Promise<void>((resolve) => setTimeout(resolve, 1000)), // 1s timeout
]),
);
await Promise.allSettled(terminations);
activeWorkers.clear();
});
// Use Promise.allSettled() instead of Promise.all() for concurrent operations
const results = await Promise.allSettled(
workers.map((worker) => worker.performOperation()),
);
// Check results individually
results.forEach((result, index) => {
if (result.status === "fulfilled") {
expect(result.value).toBeDefined();
} else {
console.warn(`Worker ${index} failed:`, result.reason);
}
});Problem: "Cannot log after tests are done" errors in performance tests due to async operations continuing after test completion.
Solution: Ensure complete async operation lifecycle management:
// DON'T: Allow async operations to continue after test
test("benchmark performance", async () => {
const operations = [];
for (let i = 0; i < 1000; i++) {
operations.push(performAsyncOperation(i)); // May continue after test ends
}
const results = await Promise.all(operations);
console.log("Performance results:", results); // May log after test completion
});
// DO: Use proper lifecycle management with explicit synchronization
test("benchmark performance", async () => {
const operations = [];
const abortController = new AbortController();
try {
for (let i = 0; i < 1000; i++) {
operations.push(
performAsyncOperation(i, { signal: abortController.signal }),
);
}
const results = await Promise.allSettled(operations);
const successful = results.filter((r) => r.status === "fulfilled");
// Log immediately, before any potential async continuation
expect(successful.length).toBeGreaterThan(900); // Allow some failures
} finally {
// Ensure all operations are cancelled
abortController.abort();
// Wait for any cleanup to complete
await new Promise((resolve) => setImmediate(resolve));
}
});Problem: Exact timing assertions fail due to CI environment variability and timer precision limitations.
Solution: Use statistical timing validation:
// DON'T: Test exact timing
const start = Date.now();
await operationWithTimeout(100);
const duration = Date.now() - start;
expect(duration).toBe(100); // Will fail due to timing precision
// DO: Use ranges with platform-aware margins
const start = process.hrtime.bigint();
await operationWithTimeout(100);
const duration = Number(process.hrtime.bigint() - start) / 1_000_000; // Convert to ms
const expectedDuration = 100;
const margin = process.env.CI ? 50 : 20; // Larger margin in CI
expect(duration).toBeGreaterThanOrEqual(expectedDuration - margin);
expect(duration).toBeLessThanOrEqual(expectedDuration + margin);Problem: Tests behave differently in local vs CI environments, causing flaky failures.
Solution: Implement environment detection and adaptive configuration:
// Environment detection helper
export function getTestEnvironment() {
const isCI = process.env.CI === "true";
const isGitHubActions = process.env.GITHUB_ACTIONS === "true";
const isLocal = !isCI;
return {
isCI,
isGitHubActions,
isLocal,
// Adaptive configuration
concurrencyLimit: isCI ? 2 : 4,
retryAttempts: isCI ? 3 : 1,
timeoutMultiplier: isCI ? 3 : 1,
// Platform-specific adjustments
shouldSkipHeavyTests: isAlpine && isARM64,
shouldUseSequentialExecution: process.platform === "win32" && isCI,
};
}
// Use in test configuration
const env = getTestEnvironment();
beforeAll(() => {
if (env.shouldUseSequentialExecution) {
jest.retryTimes(env.retryAttempts);
}
});Problem: Tests using random data or timestamps can be inconsistent across runs.
Solution: Use deterministic data generation with seeded randomness:
// DON'T: Use truly random data
test("database operations", () => {
const randomId = Math.random(); // Different every run
const timestamp = Date.now(); // Different every run
db.prepare("INSERT INTO test VALUES (?, ?)").run(randomId, timestamp);
// Test behavior becomes unpredictable
});
// DO: Use seeded deterministic data
import { createHash } from "node:crypto";
function deterministicRandom(seed: string): number {
const hash = createHash("sha256").update(seed).digest("hex");
return parseInt(hash.substring(0, 8), 16) / 0xffffffff;
}
test("database operations", () => {
const testSeed = "test-database-operations"; // Consistent across runs
const deterministicId = deterministicRandom(testSeed + "-id");
const deterministicTimestamp = 1704067200000; // Fixed timestamp: 2024-01-01
db.prepare("INSERT INTO test VALUES (?, ?)").run(
deterministicId,
deterministicTimestamp,
);
// Test behavior is now predictable and reproducible
});- Environment Detection: Auto-detect emulated environments and adjust test behavior accordingly
- Dynamic Value Handling: Focus on type validation rather than exact values for changing metadata
- Resource Lifecycle: Implement comprehensive cleanup for workers, timers, and async operations
- Statistical Validation: Use ranges and statistical analysis instead of exact timing assertions
- Deterministic Data: Prefer seeded randomness over truly random data for consistent test results
- Adaptive Configuration: Adjust concurrency, timeouts, and retry logic based on environment
- Graceful Degradation: Skip or modify tests that can't work reliably in certain environments