diff --git a/README.md b/README.md index d9a7947..0615662 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,12 @@ repo.register({ Payment, CardPayment }); - Per-model invalidation markers (`$Model`) to evict request caches without dropping entry caches. - Centralized in-memory cache across processes with UDP-based clustering. - Discovery protocol auto-syncs peer list for invalidations. +- Repository Context + - **NEW**: Repository-wide context accessible in transactions, models, and records. + - `get_context(key, defaultValue)` retrieves context values. + - `set_context(key, value)` stores context values. + - Transactions inherit parent context but modifications are isolated. + - Use cases: multi-tenancy, user context, feature flags, request metadata. - Schema sync - Create/update tables from model fields with `repo.sync()`. - Migration-safe helpers for column replacement and index updates. diff --git a/docs/api-reference.md b/docs/api-reference.md index a6c5cbc..4b59cd8 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -133,6 +133,66 @@ Synchronizes database schema from model definitions. **Use only in development!* await repo.sync({ force: true }); ``` +### `repo.get_context(key, defaultValue)` + +Retrieves a context value from the repository. + +**Parameters:** +- `key` (string): Context key to retrieve +- `defaultValue` (any, optional): Value to return if key doesn't exist + +**Returns:** any - The context value or default value + +**Example:** +```js +// Set context +repo.set_context('tenant_id', 'tenant-123'); +repo.set_context('user_role', 'admin'); + +// Get context +const tenantId = repo.get_context('tenant_id'); // 'tenant-123' +const locale = repo.get_context('locale', 'en-US'); // Returns default 'en-US' +``` + +### `repo.set_context(key, value)` + +Sets a context value in the repository. + +**Parameters:** +- `key` (string): Context key +- `value` (any): Value to store + +**Returns:** Repository instance (chainable) + +**Example:** +```js +// Store multi-tenancy context +repo.set_context('tenant_id', 'tenant-123'); + +// Store user context +repo.set_context('current_user_id', 999); +repo.set_context('current_user_role', 'admin'); + +// Store feature flags +repo.set_context('feature_new_ui', true); + +// Store request metadata +repo.set_context('request_id', 'req-abc123'); +repo.set_context('request_timestamp', new Date()); +``` + +**Use Cases:** +- Multi-tenancy: Store tenant ID for filtering queries +- User context: Track current user for audit trails +- Feature flags: Enable/disable features at runtime +- Request metadata: Store request ID, timestamp, locale +- Configuration: Runtime settings without global variables + +**Transaction Behavior:** +- Transactions inherit parent context at creation +- Changes in transactions are isolated (don't affect parent) +- Context is accessible from models and records within the transaction + --- ## Model - Query Methods @@ -416,6 +476,50 @@ const count = await Users.query() .first(); ``` +### `Model.get_context(key, defaultValue)` + +Gets a context value from the repository. + +**Parameters:** +- `key` (string): Context key to retrieve +- `defaultValue` (any, optional): Value to return if key doesn't exist + +**Returns:** any - The context value or default value + +**Example:** +```js +const Users = repo.get('Users'); + +// Get context from model +const tenantId = Users.get_context('tenant_id'); + +// Use in model methods +class Users { + static async findForCurrentTenant() { + const tenantId = this.get_context('tenant_id'); + return this.where({ tenant_id: tenantId }).find(); + } +} +``` + +### `Model.set_context(key, value)` + +Sets a context value in the repository. + +**Parameters:** +- `key` (string): Context key +- `value` (any): Value to store + +**Returns:** Model instance (chainable) + +**Example:** +```js +const Users = repo.get('Users'); + +// Set context from model +Users.set_context('last_query_time', new Date()); +``` + --- ## Record - Instance Methods @@ -463,6 +567,50 @@ await user.unlink(); console.log('User deleted'); ``` +### `record.get_context(key, defaultValue)` + +Gets a context value from the repository. + +**Parameters:** +- `key` (string): Context key to retrieve +- `defaultValue` (any, optional): Value to return if key doesn't exist + +**Returns:** any - The context value or default value + +**Example:** +```js +const user = await Users.findById(1); + +// Get context from record +const tenantId = user.get_context('tenant_id'); + +// Use in hooks +class Users { + async pre_create() { + const currentUserId = this.get_context('current_user_id'); + this.created_by = currentUserId; + } +} +``` + +### `record.set_context(key, value)` + +Sets a context value in the repository. + +**Parameters:** +- `key` (string): Context key +- `value` (any): Value to store + +**Returns:** Record instance (chainable) + +**Example:** +```js +const user = await Users.findById(1); + +// Set context from record +user.set_context('last_accessed_user_id', user.id); +``` + --- ## Record - Relation Methods diff --git a/docs/cookbook.md b/docs/cookbook-old.md similarity index 100% rename from docs/cookbook.md rename to docs/cookbook-old.md diff --git a/docs/cookbook/authentication.md b/docs/cookbook/authentication.md new file mode 100644 index 0000000..4a031c5 --- /dev/null +++ b/docs/cookbook/authentication.md @@ -0,0 +1,112 @@ +--- +id: authentication +title: Authentication +keywords: [authentication, password, bcrypt, session, login, security] +--- + +# Authentication + +## Password Hashing (with bcrypt) + +```js +// ✅ User model with password hashing +class Users { + static _name = 'Users'; + static fields = { + id: 'primary', + email: { type: 'string', unique: true, required: true }, + password_hash: 'string' + }; + + // Set password with hashing + async setPassword(password) { + const bcrypt = require('bcrypt'); + const hash = await bcrypt.hash(password, 10); + await this.write({ password_hash: hash }); + } + + // Verify password + async verifyPassword(password) { + const bcrypt = require('bcrypt'); + return bcrypt.compare(password, this.password_hash); + } + + // Static method for authentication + static async authenticate(email, password) { + const user = await this.where({ email }).first(); + if (!user) return null; + + const valid = await user.verifyPassword(password); + return valid ? user : null; + } +} + +// Usage +const Users = repo.get('Users'); + +// Register +const user = await Users.create({ email: 'john@example.com' }); +await user.setPassword('secretpassword'); + +// Login +const user = await Users.authenticate('john@example.com', 'secretpassword'); +if (!user) { + throw new Error('Invalid credentials'); +} +``` + +## Session Management + +```js +// ✅ Session model +class Sessions { + static _name = 'Sessions'; + static fields = { + id: 'primary', + user_id: { type: 'many-to-one', model: 'Users' }, + token: { type: 'string', unique: true }, + expires_at: 'datetime', + created_at: { type: 'datetime', default: () => new Date() } + }; + + // Check if session is valid + get isValid() { + return this.expires_at > new Date(); + } + + // Create new session + static async createForUser(userId, ttlSeconds = 86400) { + const crypto = require('crypto'); + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + ttlSeconds * 1000); + + return this.create({ + user_id: userId, + token, + expires_at: expiresAt + }); + } + + // Find by token + static async findByToken(token) { + const session = await this.where({ token }).first(); + if (!session || !session.isValid) { + return null; + } + return session; + } +} + +// Usage +const Sessions = repo.get('Sessions'); + +// Create session +const session = await Sessions.createForUser(user.id); +console.log('Session token:', session.token); + +// Validate session +const session = await Sessions.findByToken(token); +if (!session) { + throw new Error('Invalid or expired session'); +} +``` diff --git a/docs/cookbook/context.md b/docs/cookbook/context.md new file mode 100644 index 0000000..6923887 --- /dev/null +++ b/docs/cookbook/context.md @@ -0,0 +1,347 @@ +--- +id: context +title: Repository Context +keywords: [context, multi-tenancy, request metadata, feature flags, tenant] +--- + +# Repository Context + +Use repository-wide context to store and access configuration, user information, and request metadata throughout your application. + +## Overview + +The context API provides a simple key-value store at the repository level: +- **`get_context(key, defaultValue)`** - Retrieve context values +- **`set_context(key, value)`** - Store context values + +Context is accessible from: +- Repository instances +- Model classes +- Record instances +- Transaction-scoped repositories (with inheritance) + +## Basic Usage + +### Setting and Getting Context + +```js +const repo = new Repository(conn); + +// Set context values +repo.set_context('tenant_id', 'tenant-123'); +repo.set_context('user_role', 'admin'); +repo.set_context('feature_flags', { newUI: true, betaAPI: false }); + +// Get context values +const tenantId = repo.get_context('tenant_id'); // 'tenant-123' +const role = repo.get_context('user_role'); // 'admin' + +// Get with default value +const locale = repo.get_context('locale', 'en-US'); // Returns 'en-US' +``` + +### Accessing Context from Models + +```js +class Users { + static _name = 'Users'; + static fields = { + id: 'primary', + email: 'string', + tenant_id: 'string' + }; + + // Static method using context + static async findForCurrentTenant() { + const tenantId = this.get_context('tenant_id'); + return this.where({ tenant_id: tenantId }).find(); + } +} + +// Usage +const Users = repo.get('Users'); +repo.set_context('tenant_id', 'tenant-123'); + +const users = await Users.findForCurrentTenant(); +``` + +### Accessing Context from Records + +```js +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + author_id: 'integer', + created_by: 'integer' + }; + + // Pre-create hook using context + async pre_create() { + // Automatically set created_by from context + const currentUserId = this.get_context('current_user_id'); + if (currentUserId) { + this.created_by = currentUserId; + } + } + + // Method using context + canEdit() { + const currentUserId = this.get_context('current_user_id'); + const currentRole = this.get_context('current_user_role'); + + return currentRole === 'admin' || this.author_id === currentUserId; + } +} + +// Usage +repo.set_context('current_user_id', 999); +repo.set_context('current_user_role', 'user'); + +const Posts = repo.get('Posts'); +const post = await Posts.create({ title: 'My Post', author_id: 999 }); +// created_by is automatically set to 999 + +console.log(post.canEdit()); // true +``` + +## Transaction Context Inheritance + +Transactions inherit the parent repository's context, but modifications are isolated. + +```js +// Set context in parent +repo.set_context('tenant_id', 'tenant-123'); +repo.set_context('user_role', 'admin'); + +await repo.transaction(async tx => { + // Transaction inherits parent context + console.log(tx.get_context('tenant_id')); // 'tenant-123' + console.log(tx.get_context('user_role')); // 'admin' + + // Modify context in transaction (isolated) + tx.set_context('tenant_id', 'tenant-456'); + tx.set_context('tx_timestamp', new Date()); + + console.log(tx.get_context('tenant_id')); // 'tenant-456' + + const Users = tx.get('Users'); + // Models in transaction use transaction context + console.log(Users.get_context('tenant_id')); // 'tenant-456' +}); + +// Parent context unchanged +console.log(repo.get_context('tenant_id')); // Still 'tenant-123' +console.log(repo.get_context('tx_timestamp')); // undefined +``` + +## Common Use Cases + +### Multi-Tenancy + +Store tenant ID in context to filter all queries: + +```js +// Middleware sets tenant from request +app.use(async (req, res, next) => { + const tenantId = req.user.tenantId; + req.repo = new Repository(conn); + req.repo.set_context('tenant_id', tenantId); + next(); +}); + +// Models automatically use tenant context +class Products { + static _name = 'Products'; + static fields = { + id: 'primary', + name: 'string', + tenant_id: 'string' + }; + + // Override create to auto-add tenant_id + static async create(data) { + const tenantId = this.get_context('tenant_id'); + return super.create({ ...data, tenant_id: tenantId }); + } + + // Helper to get tenant-scoped query + static forTenant() { + const tenantId = this.get_context('tenant_id'); + return this.where({ tenant_id: tenantId }); + } +} + +// Usage in route handler +app.get('/products', async (req, res) => { + const Products = req.repo.get('Products'); + const products = await Products.forTenant().find(); + res.json(products); +}); +``` + +### Audit Trail + +Track who creates/updates records: + +```js +// Middleware sets user context +app.use(async (req, res, next) => { + if (req.user) { + req.repo.set_context('current_user_id', req.user.id); + req.repo.set_context('current_user_name', req.user.name); + } + next(); +}); + +// Model with audit fields +class Documents { + static _name = 'Documents'; + static fields = { + id: 'primary', + title: 'string', + created_by: 'integer', + updated_by: 'integer', + created_at: { type: 'datetime', default: () => new Date() }, + updated_at: { type: 'datetime', default: () => new Date() } + }; + + async pre_create() { + const userId = this.get_context('current_user_id'); + if (userId) { + this.created_by = userId; + this.updated_by = userId; + } + } + + async pre_update() { + const userId = this.get_context('current_user_id'); + if (userId) { + this.updated_by = userId; + } + this.updated_at = new Date(); + } +} +``` + +### Feature Flags + +Control feature availability via context: + +```js +// Application startup +repo.set_context('features', { + newDashboard: true, + betaAPI: false, + experimentalSearch: true +}); + +// Check feature availability +class Posts { + static _name = 'Posts'; + + static async search(term) { + const features = this.get_context('features', {}); + + if (features.experimentalSearch) { + // Use new search algorithm + return this.searchExperimental(term); + } else { + // Use old search + return this.searchLegacy(term); + } + } +} +``` + +### Request Metadata + +Store request-specific data: + +```js +// Middleware sets request context +app.use(async (req, res, next) => { + req.repo = new Repository(conn); + req.repo.set_context('request_id', req.id); + req.repo.set_context('request_ip', req.ip); + req.repo.set_context('request_timestamp', new Date()); + req.repo.set_context('locale', req.headers['accept-language'] || 'en'); + next(); +}); + +// Use in models +class ErrorLogs { + static _name = 'ErrorLogs'; + + static async logError(error, details) { + return this.create({ + message: error.message, + stack: error.stack, + details, + request_id: this.get_context('request_id'), + ip_address: this.get_context('request_ip'), + occurred_at: this.get_context('request_timestamp') + }); + } +} +``` + +### Internationalization + +Store locale for translations: + +```js +// Set locale from request +repo.set_context('locale', 'es-ES'); + +class Products { + static _name = 'Products'; + + get localizedDescription() { + const locale = this.get_context('locale', 'en-US'); + const translations = JSON.parse(this.description_i18n || '{}'); + return translations[locale] || this.description; + } +} +``` + +## Best Practices + +### ✅ DO + +```js +// Set context at the start of request/transaction +repo.set_context('tenant_id', tenantId); +repo.set_context('user_id', userId); + +// Use context for cross-cutting concerns +const tenantId = this.get_context('tenant_id'); +const userId = this.get_context('current_user_id'); + +// Provide sensible defaults +const locale = this.get_context('locale', 'en-US'); +const timeout = this.get_context('query_timeout', 30000); +``` + +### ❌ DON'T + +```js +// Don't store large objects in context +repo.set_context('all_users', await Users.find()); // Bad! + +// Don't use for business logic state +repo.set_context('shopping_cart', cartItems); // Use proper models instead + +// Don't rely on context being set +const tenantId = this.get_context('tenant_id'); // Might be undefined! +if (!tenantId) { + throw new Error('tenant_id not set'); +} +``` + +## Related Documentation + +- [API Reference](../api-reference#repo-context) - Complete context API +- [Transactions](transactions) - Transaction patterns +- [Multi-Tenancy Use Case](../use-cases#multi-tenancy) - Full multi-tenant example diff --git a/docs/cookbook/crud-operations.md b/docs/cookbook/crud-operations.md new file mode 100644 index 0000000..1365b4c --- /dev/null +++ b/docs/cookbook/crud-operations.md @@ -0,0 +1,151 @@ +--- +id: crud-operations +title: CRUD Operations +keywords: [create, read, update, delete, crud, records] +--- + +# CRUD Operations + +## Create a Record + +```js +const Users = repo.get('Users'); + +// ✅ Simple create +const user = await Users.create({ + email: 'john@example.com', + name: 'John Doe' +}); + +// ✅ Create with default values +const user = await Users.create({ + email: 'jane@example.com' + // 'active' field uses default: true + // 'created_at' uses default: () => new Date() +}); + +// ❌ DON'T: Create without transaction for important data +const user = await Users.create({ email }); // Risky if part of larger operation + +// ✅ DO: Use transactions for important creates +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const user = await Users.create({ email, name }); +}); +``` + +## Read Records + +```js +const Users = repo.get('Users'); + +// ✅ Find by ID (uses cache if enabled) +const user = await Users.findById(1); +if (!user) { + throw new Error('User not found'); +} + +// ✅ Find one by criteria +const user = await Users.where({ email: 'john@example.com' }).first(); + +// ✅ Find many with filters +const activeUsers = await Users + .where({ active: true }) + .orderBy('created_at', 'desc') + .limit(20) + .find(); + +// ✅ Find with multiple conditions +const users = await Users + .where({ active: true }) + .where('created_at', '>', lastWeek) + .find(); + +// ✅ Check if exists +const exists = await Users.where({ email }).count() > 0; +``` + +## Update Records + +```js +const Users = repo.get('Users'); + +// ✅ Method 1: Direct modification (optimal for auto-persist) +const user = await Users.findById(1); +user.name = 'Jane Smith'; +user.updated_at = new Date(); +// Changes persist automatically on next query or transaction flush + +// ✅ Method 2: write() with key/value pairs (immediate flush) +const user = await Users.findById(1); +await user.write({ + name: 'Jane Smith', + updated_at: new Date() +}); + +// ✅ Update with validation +const user = await Users.findById(1); +if (user.role !== 'admin') { + await user.write({ email: newEmail }); +} + +// ❌ DON'T: Bulk update using Knex directly (bypasses hooks!) +await Users.query() + .where('last_login', '<', sixMonthsAgo) + .update({ active: false }); // Bypasses validation, hooks, NormalJS internals! + +// ✅ DO: Use transaction with individual updates (hooks run correctly) +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const users = await Users.where('last_login', '<', sixMonthsAgo).find(); + for (const user of users) { + await user.write({ active: false }); + } +}); + +// ❌ DON'T: Use update() method +await user.update({ email: 'new@example.com' }); // Method doesn't exist! + +// ❌ DON'T: Modify then call write() without arguments +user.email = 'new@example.com'; +await user.write(); // Anti-pattern! + +// ✅ DO: Use one of the two correct methods +user.email = 'new@example.com'; // Direct (auto-persists) +// OR +await user.write({ email: 'new@example.com' }); // Immediate flush +``` + +## Delete Records + +```js +const Users = repo.get('Users'); + +// ✅ Delete single record +const user = await Users.findById(1); +await user.unlink(); + +// ✅ Delete with check +const user = await Users.where({ email }).first(); +if (user && !user.is_protected) { + await user.unlink(); +} + +// ❌ DON'T: Bulk delete using Knex directly (bypasses hooks!) +await Users.query() + .where('created_at', '<', oneYearAgo) + .where('active', false) + .delete(); // Bypasses hooks and cascade logic! + +// ✅ DO: Use transaction with individual deletes (hooks run correctly) +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const users = await Users + .where('created_at', '<', oneYearAgo) + .where('active', false) + .find(); + for (const user of users) { + await user.unlink(); + } +}); +``` diff --git a/docs/cookbook/file-uploads.md b/docs/cookbook/file-uploads.md new file mode 100644 index 0000000..29123cf --- /dev/null +++ b/docs/cookbook/file-uploads.md @@ -0,0 +1,74 @@ +--- +id: file-uploads +title: File Uploads +keywords: [file upload, file storage, metadata, mime type, attachments] +--- + +# File Uploads + +## File Metadata Model + +```js +// ✅ File upload model +class Uploads { + static _name = 'Uploads'; + static fields = { + id: 'primary', + filename: 'string', + original_name: 'string', + mime_type: 'string', + size: 'integer', + path: 'string', + user_id: { type: 'many-to-one', model: 'Users' }, + created_at: { type: 'datetime', default: () => new Date() } + }; + + // Get public URL + get url() { + return `/uploads/${this.filename}`; + } + + // Get human-readable size + get humanSize() { + const bytes = this.size; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + // Create from file + static async createFromFile(filePath, originalName, userId) { + const fs = require('fs'); + const path = require('path'); + const crypto = require('crypto'); + + const stats = fs.statSync(filePath); + const ext = path.extname(originalName); + const filename = crypto.randomBytes(16).toString('hex') + ext; + + // You would move/copy the file here + // fs.renameSync(filePath, `/uploads/${filename}`); + + return this.create({ + filename, + original_name: originalName, + mime_type: 'application/octet-stream', // Detect from file + size: stats.size, + path: `/uploads/${filename}`, + user_id: userId + }); + } +} + +// Usage +const Uploads = repo.get('Uploads'); + +const upload = await Uploads.createFromFile( + '/tmp/upload-abc123', + 'document.pdf', + userId +); + +console.log(`File uploaded: ${upload.url}`); +console.log(`Size: ${upload.humanSize}`); +``` diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md new file mode 100644 index 0000000..b60d33a --- /dev/null +++ b/docs/cookbook/index.md @@ -0,0 +1,40 @@ +--- +id: cookbook +title: Cookbook - Common Recipes +keywords: [recipes, examples, howto, patterns, code samples] +--- + +# Cookbook + +Copy-paste ready recipes for common tasks with NormalJS. All examples are production-ready and follow best practices. + +## Recipe Categories + +### Core Operations +- [CRUD Operations](crud-operations) - Create, Read, Update, Delete patterns +- [Queries & Filtering](queries-filtering) - Simple and complex query patterns +- [Relations](relations) - Working with one-to-many and many-to-many relationships +- [Transactions](transactions) - Transaction patterns and best practices + +### Common Features +- [Authentication](authentication) - Password hashing and session management +- [Pagination](pagination) - Simple offset-based and cursor-based pagination +- [Context](context) - Using repository context for multi-tenancy and request metadata +- [Timestamps](timestamps) - Automatic created_at and updated_at tracking +- [Soft Deletes](soft-deletes) - Implementing soft delete patterns +- [Slugs & SEO](slugs-seo) - Auto-generate URL-friendly slugs + +### Advanced Topics +- [File Uploads](file-uploads) - File metadata and storage patterns +- [Validation](validation) - Custom validation in hooks +- [Search](search) - Full-text search patterns + +## Quick Links + +- [API Reference](../api-reference) - Complete API documentation +- [Use Cases](../use-cases) - Real-world application examples +- [Custom Fields](../custom-fields) - Build your own field types + +## Contributing Recipes + +Have a useful pattern? Submit a PR to add it to the cookbook! diff --git a/docs/cookbook/pagination.md b/docs/cookbook/pagination.md new file mode 100644 index 0000000..c4b69d8 --- /dev/null +++ b/docs/cookbook/pagination.md @@ -0,0 +1,108 @@ +--- +id: pagination +title: Pagination +keywords: [pagination, limit, offset, cursor, infinite scroll] +--- + +# Pagination + +## Simple Pagination + +```js +// ✅ Basic pagination helper +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + published: 'boolean' + }; + + // Static pagination method + static async paginate(page = 1, perPage = 20, filters = {}) { + const offset = (page - 1) * perPage; + + // Build base query + let query = this.where(filters); + + // Get items and total count in parallel + const [items, total] = await Promise.all([ + query.limit(perPage).offset(offset).find(), + query.count() + ]); + + return { + items, + total, + page, + perPage, + totalPages: Math.ceil(total / perPage), + hasNext: page * perPage < total, + hasPrev: page > 1 + }; + } +} + +// Usage +const Posts = repo.get('Posts'); +const result = await Posts.paginate(2, 20, { published: true }); + +console.log(`Page ${result.page} of ${result.totalPages}`); +console.log(`Showing ${result.items.length} of ${result.total} posts`); +result.items.forEach(post => console.log(post.title)); +``` + +## Cursor-Based Pagination + +```js +// ✅ Cursor pagination for infinite scroll +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + created_at: 'datetime' + }; + + // Cursor-based pagination + static async paginateByCursor(cursor = null, limit = 20) { + let query = this.where({ published: true }); + + if (cursor) { + // Get posts created before cursor + query = query.where('created_at', '<', cursor); + } + + const items = await query + .orderBy('created_at', 'desc') + .limit(limit + 1) // Get one extra to check if there's more + .find(); + + const hasMore = items.length > limit; + if (hasMore) items.pop(); // Remove the extra item + + const nextCursor = items.length > 0 + ? items[items.length - 1].created_at + : null; + + return { + items, + nextCursor, + hasMore + }; + } +} + +// Usage +const Posts = repo.get('Posts'); + +// First page +const page1 = await Posts.paginateByCursor(); +console.log(`Loaded ${page1.items.length} posts`); + +// Next page +if (page1.hasMore) { + const page2 = await Posts.paginateByCursor(page1.nextCursor); + console.log(`Loaded ${page2.items.length} more posts`); +} +``` diff --git a/docs/cookbook/queries-filtering.md b/docs/cookbook/queries-filtering.md new file mode 100644 index 0000000..e6d4b65 --- /dev/null +++ b/docs/cookbook/queries-filtering.md @@ -0,0 +1,125 @@ +--- +id: queries-filtering +title: Queries & Filtering +keywords: [query, filter, where, search, orderby, limit] +--- + +# Queries & Filtering + +## Simple Filters + +```js +const Posts = repo.get('Posts'); + +// ✅ Single condition +const posts = await Posts.where({ published: true }).find(); + +// ✅ Multiple conditions (AND) +const posts = await Posts + .where({ published: true, featured: true }) + .find(); + +// ✅ Comparison operators +const posts = await Posts + .where('views', '>', 1000) + .where('created_at', '>=', lastMonth) + .find(); + +// ✅ LIKE queries +const posts = await Posts + .where('title', 'like', '%tutorial%') + .find(); + +// ✅ IN queries +const posts = await Posts + .whereIn('category_id', [1, 2, 3]) + .find(); +``` + +## Complex Filters with JSON Criteria + +```js +const Posts = repo.get('Posts'); + +// ✅ OR conditions +const posts = await Posts.where({ + or: [ + ['published', '=', true], + ['author_id', '=', currentUserId] + ] +}).find(); + +// ✅ Nested AND/OR +const posts = await Posts.where({ + and: [ + ['published', '=', true], + { + or: [ + ['featured', '=', true], + ['views', '>', 1000] + ] + } + ] +}).find(); + +// ✅ Complex search +const posts = await Posts.where({ + and: [ + ['published', '=', true], + { + or: [ + ['title', 'like', `%${searchTerm}%`], + ['content', 'like', `%${searchTerm}%`] + ] + }, + ['created_at', '>', startDate] + ] +}).orderBy('views', 'desc').find(); +``` + +## Sorting and Limiting + +```js +const Posts = repo.get('Posts'); + +// ✅ Order by single field +const posts = await Posts + .where({ published: true }) + .orderBy('created_at', 'desc') + .find(); + +// ✅ Order by multiple fields +const posts = await Posts + .where({ published: true }) + .orderBy('featured', 'desc') + .orderBy('views', 'desc') + .find(); + +// ✅ Pagination +const page = 2; +const perPage = 20; +const posts = await Posts + .where({ published: true }) + .limit(perPage) + .offset((page - 1) * perPage) + .find(); +``` + +## Counting + +```js +const Posts = repo.get('Posts'); + +// ✅ Count all +const total = await Posts.count(); + +// ✅ Count with filters +const publishedCount = await Posts + .where({ published: true }) + .count(); + +// ✅ Check existence +const hasUnpublished = await Posts + .where({ published: false }) + .count() > 0; +``` diff --git a/docs/cookbook/relations.md b/docs/cookbook/relations.md new file mode 100644 index 0000000..e15bd19 --- /dev/null +++ b/docs/cookbook/relations.md @@ -0,0 +1,129 @@ +--- +id: relations +title: Relations +keywords: [relations, foreign key, one-to-many, many-to-one, many-to-many, eager loading] +--- + +# Relations + +## Defining Relations + +```js +// ✅ Many-to-One (belongs to) +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + author_id: { type: 'many-to-one', model: 'Users' } // Creates FK column + }; +} + +// ✅ One-to-Many (has many) +class Users { + static _name = 'Users'; + static fields = { + id: 'primary', + email: 'string', + posts: { type: 'one-to-many', foreign: 'Posts.author_id' } // Virtual field + }; +} + +// ✅ Many-to-Many +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + tags: { type: 'many-to-many', model: 'Tags' } // Auto-creates join table + }; +} + +class Tags { + static _name = 'Tags'; + static fields = { + id: 'primary', + posts: { type: 'many-to-many', model: 'Posts' } // Same join table + }; +} +``` + +## Eager Loading Relations + +```js +const Posts = repo.get('Posts'); +const Users = repo.get('Users'); + +// ✅ Load single relation +const post = await Posts + .where({ id: 1 }) + .include('author') + .first(); +console.log(post.author.name); + +// ✅ Load multiple relations +const post = await Posts + .where({ id: 1 }) + .include('author', 'tags', 'comments') + .first(); + +// ✅ Load relation on collection +const posts = await Posts + .where({ published: true }) + .include('author') + .find(); +posts.forEach(post => console.log(post.author.name)); + +// ❌ DON'T: Access unloaded relations +const post = await Posts.findById(1); +console.log(post.author.name); // May be undefined! + +// ✅ DO: Load explicitly or use include +const post = await Posts.findById(1); +await post.author.load(); +console.log(post.author.name); +``` + +## Lazy Loading Relations + +```js +const Posts = repo.get('Posts'); +const Users = repo.get('Users'); + +// ✅ Load one-to-many +const user = await Users.findById(1); +await user.posts.load(); +console.log(`User has ${user.posts.items.length} posts`); + +// ✅ Load with filters +const user = await Users.findById(1); +const publishedPosts = await user.posts.where({ published: true }); + +// ✅ Load many-to-many +const post = await Posts.findById(1); +await post.tags.load(); +post.tags.items.forEach(tag => console.log(tag.name)); +``` + +## Managing Many-to-Many Relations + +```js +const Posts = repo.get('Posts'); + +// ✅ Add relation +const post = await Posts.findById(1); +await post.tags.add(tagId); +await post.tags.add(tagObject); + +// ✅ Remove relation +await post.tags.remove(tagId); + +// ✅ Replace all relations +await post.tags.set([tag1Id, tag2Id, tag3Id]); + +// ✅ Clear all relations +await post.tags.set([]); + +// ✅ Check if related +await post.tags.load(); +const hasTag = post.tags.items.some(t => t.id === tagId); +``` diff --git a/docs/cookbook/search.md b/docs/cookbook/search.md new file mode 100644 index 0000000..369f7fd --- /dev/null +++ b/docs/cookbook/search.md @@ -0,0 +1,98 @@ +--- +id: search +title: Search +keywords: [search, full-text search, like, postgresql, tsvector, ranking] +--- + +# Search + +## Simple Full-Text Search + +```js +// ✅ Basic search across multiple fields +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + content: 'text', + published: 'boolean' + }; + + // Search method + static search(term, filters = {}) { + const searchPattern = `%${term}%`; + + return this.where({ + and: [ + ...Object.entries(filters).map(([k, v]) => [k, '=', v]), + { + or: [ + ['title', 'like', searchPattern], + ['content', 'like', searchPattern] + ] + } + ] + }); + } +} + +// Usage +const Posts = repo.get('Posts'); + +const results = await Posts + .search('javascript', { published: true }) + .orderBy('created_at', 'desc') + .limit(10) + .find(); + +console.log(`Found ${results.length} posts matching "javascript"`); +``` + +## Search with Relevance Scoring (PostgreSQL) + +```js +// ✅ Full-text search with PostgreSQL +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + content: 'text', + search_vector: 'text' // tsvector column + }; + + // Search with ranking + static async searchFullText(term, limit = 20) { + const query = this.query() + .select('*') + .select(this.query().raw( + "ts_rank(search_vector, plainto_tsquery('english', ?)) as rank", + [term] + )) + .whereRaw( + "search_vector @@ plainto_tsquery('english', ?)", + [term] + ) + .orderBy('rank', 'desc') + .limit(limit); + + return query; + } + + // Update search vector (trigger or hook) + async post_update() { + if ('title' in this._changes || 'content' in this._changes) { + // Update search vector for full-text search + await this.query() + .where({ id: this.id }) + .update({ + search_vector: this.query().raw( + "to_tsvector('english', ? || ' ' || ?)", + [this.title, this.content] + ) + }); + } + } +} +``` diff --git a/docs/cookbook/slugs-seo.md b/docs/cookbook/slugs-seo.md new file mode 100644 index 0000000..023f54f --- /dev/null +++ b/docs/cookbook/slugs-seo.md @@ -0,0 +1,75 @@ +--- +id: slugs-seo +title: Slugs & SEO +keywords: [slug, url, seo, permalink, unique slug] +--- + +# Slugs & SEO + +## Auto-Generate Slugs + +```js +// ✅ Slug generation +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: { type: 'string', required: true }, + slug: { type: 'string', unique: true } + }; + + // Generate slug from title + static generateSlug(title) { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); + } + + // Ensure unique slug + static async ensureUniqueSlug(slug, excludeId = null) { + let uniqueSlug = slug; + let counter = 1; + + while (true) { + let query = this.where({ slug: uniqueSlug }); + if (excludeId) { + query = query.where('id', '!=', excludeId); + } + + const exists = await query.count() > 0; + if (!exists) break; + + uniqueSlug = `${slug}-${counter++}`; + } + + return uniqueSlug; + } + + // Post-create hook to generate slug + async post_create() { + if (!this.slug) { + const baseSlug = Posts.generateSlug(this.title); + const uniqueSlug = await Posts.ensureUniqueSlug(baseSlug, this.id); + await this.write({ slug: uniqueSlug }); + } + } + + // Find by slug + static bySlug(slug) { + return this.where({ slug }).first(); + } +} + +// Usage +const Posts = repo.get('Posts'); + +// Create with auto-slug +const post = await Posts.create({ + title: 'Hello World!' +}); +console.log(post.slug); // "hello-world" + +// Find by slug +const post = await Posts.bySlug('hello-world'); +``` diff --git a/docs/cookbook/soft-deletes.md b/docs/cookbook/soft-deletes.md new file mode 100644 index 0000000..48cb06f --- /dev/null +++ b/docs/cookbook/soft-deletes.md @@ -0,0 +1,62 @@ +--- +id: soft-deletes +title: Soft Deletes +keywords: [soft delete, deleted_at, restore, archive, undelete] +--- + +# Soft Deletes + +## Implementing Soft Deletes + +```js +// ✅ Soft delete pattern +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + deleted_at: 'datetime' + }; + + // Soft delete + async softDelete() { + await this.write({ deleted_at: new Date() }); + } + + // Restore + async restore() { + await this.write({ deleted_at: null }); + } + + // Check if deleted + get isDeleted() { + return this.deleted_at !== null; + } + + // Query only active records + static active() { + return this.where({ deleted_at: null }); + } + + // Query only deleted records + static deleted() { + return this.where('deleted_at', 'is not', null); + } +} + +// Usage +const Posts = repo.get('Posts'); + +// Get only active posts +const activePosts = await Posts.active().find(); + +// Soft delete +const post = await Posts.findById(1); +await post.softDelete(); + +// Restore +await post.restore(); + +// Get deleted posts +const deletedPosts = await Posts.deleted().find(); +``` diff --git a/docs/cookbook/timestamps.md b/docs/cookbook/timestamps.md new file mode 100644 index 0000000..8bb45a7 --- /dev/null +++ b/docs/cookbook/timestamps.md @@ -0,0 +1,55 @@ +--- +id: timestamps +title: Timestamps +keywords: [timestamps, created_at, updated_at, hooks, datetime] +--- + +# Timestamps + +## Auto Timestamps + +```js +// ✅ Model with automatic timestamps +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + created_at: { type: 'datetime', default: () => new Date() }, + updated_at: { type: 'datetime', default: () => new Date() } + }; +} + +// ✅ Update with timestamp +const post = await Posts.findById(1); +await post.write({ + title: 'New Title', + updated_at: new Date() +}); +``` + +## Post-Create Hook for Timestamps + +```js +// ✅ Automatic updated_at using hooks +class Posts { + static _name = 'Posts'; + static fields = { + id: 'primary', + title: 'string', + created_at: { type: 'datetime', default: () => new Date() }, + updated_at: { type: 'datetime', default: () => new Date() } + }; + + // Post-create hook + async post_create() { + // Run after record is created + console.log(`Post ${this.id} created at ${this.created_at}`); + } + + // Pre-update hook + async pre_update() { + this.updated_at = new Date(); + } +} +``` diff --git a/docs/cookbook/transactions.md b/docs/cookbook/transactions.md new file mode 100644 index 0000000..4202f82 --- /dev/null +++ b/docs/cookbook/transactions.md @@ -0,0 +1,113 @@ +--- +id: transactions +title: Transactions +keywords: [transaction, atomic, rollback, commit, consistency] +--- + +# Transactions + +## Basic Transaction + +```js +// ✅ Simple transaction +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Posts = tx.get('Posts'); + + const user = await Users.create({ email: 'author@example.com' }); + await Posts.create({ title: 'First Post', author_id: user.id }); + + // Commits automatically on success +}); + +// ❌ DON'T: Mix transaction contexts +await repo.transaction(async tx => { + const user = await repo.get('Users').create({ email }); // Wrong context! + const post = await tx.get('Posts').create({ author_id: user.id }); +}); + +// ✅ DO: Use tx consistently +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Posts = tx.get('Posts'); + + const user = await Users.create({ email }); + const post = await Posts.create({ author_id: user.id }); +}); +``` + +## Transaction with Error Handling + +```js +// ✅ Transaction with rollback on error +try { + await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Accounts = tx.get('Accounts'); + + const user = await Users.create({ email }); + + // This will rollback if it throws + if (someCondition) { + throw new Error('Invalid operation'); + } + + await Accounts.create({ user_id: user.id, balance: 0 }); + }); +} catch (err) { + console.error('Transaction failed:', err.message); + // All changes rolled back +} +``` + +## Nested Operations in Transaction + +```js +// ✅ Complex multi-step transaction +await repo.transaction(async tx => { + const Users = tx.get('Users'); + const Orders = tx.get('Orders'); + const Products = tx.get('Products'); + const OrderItems = tx.get('OrderItems'); + + // 1. Get user + const user = await Users.findById(userId); + if (!user.active) { + throw new Error('User is not active'); + } + + // 2. Create order + const order = await Orders.create({ + customer_id: user.id, + status: 'pending', + total: 0 + }); + + // 3. Process items + let total = 0; + for (const item of cartItems) { + const product = await Products.findById(item.product_id); + + // Check stock + if (product.stock < item.quantity) { + throw new Error(`Not enough stock for ${product.name}`); + } + + // Deduct inventory + await product.write({ stock: product.stock - item.quantity }); + + // Create order item + await OrderItems.create({ + order_id: order.id, + product_id: product.id, + quantity: item.quantity, + price: product.price + }); + + total += product.price * item.quantity; + } + + // 4. Update order total + await order.write({ total }); +}); +``` diff --git a/docs/cookbook/validation.md b/docs/cookbook/validation.md new file mode 100644 index 0000000..0786e99 --- /dev/null +++ b/docs/cookbook/validation.md @@ -0,0 +1,62 @@ +--- +id: validation +title: Validation +keywords: [validation, validate, hooks, pre_create, pre_update, constraints] +--- + +# Validation + +## Field-Level Validation + +```js +// ✅ Custom validation in model +class Users { + static _name = 'Users'; + static fields = { + id: 'primary', + email: { type: 'string', unique: true, required: true }, + age: 'integer' + }; + + // Pre-create validation + async pre_create() { + this.validateEmail(); + this.validateAge(); + } + + // Pre-update validation + async pre_update() { + if ('email' in this._changes) { + this.validateEmail(); + } + if ('age' in this._changes) { + this.validateAge(); + } + } + + validateEmail() { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(this.email)) { + throw new Error('Invalid email format'); + } + } + + validateAge() { + if (this.age !== null && this.age !== undefined) { + if (this.age < 0 || this.age > 150) { + throw new Error('Age must be between 0 and 150'); + } + } + } +} + +// Usage (validation happens automatically) +try { + const user = await Users.create({ + email: 'invalid-email', + age: 200 + }); +} catch (err) { + console.error(err.message); // "Invalid email format" +} +``` diff --git a/src/Model.ts b/src/Model.ts index 57da8c4..bb8c175 100644 --- a/src/Model.ts +++ b/src/Model.ts @@ -701,6 +701,27 @@ class Model { findOne(where: any): Request { return this.firstWhere(where); } + + /** + * Get a context value from the repository. + * @param {string} key - The context key + * @param {any} [defaultValue] - The default value if key not found + * @returns {any} The context value or default value + */ + get_context(key: string, defaultValue?: any): any { + return this.repo.get_context(key, defaultValue); + } + + /** + * Set a context value in the repository. + * @param {string} key - The context key + * @param {any} value - The value to set + * @returns {this} + */ + set_context(key: string, value: any): this { + this.repo.set_context(key, value); + return this; + } } export { Model }; diff --git a/src/Record.ts b/src/Record.ts index 114d281..1a14683 100644 --- a/src/Record.ts +++ b/src/Record.ts @@ -338,6 +338,27 @@ class Record { } return await this.flush(); } + + /** + * Get a context value from the repository. + * @param {string} key - The context key + * @param {any} [defaultValue] - The default value if key not found + * @returns {any} The context value or default value + */ + get_context(key: string, defaultValue?: any): any { + return (this._model as any).repo.get_context(key, defaultValue); + } + + /** + * Set a context value in the repository. + * @param {string} key - The context key + * @param {any} value - The value to set + * @returns {this} + */ + set_context(key: string, value: any): this { + (this._model as any).repo.set_context(key, value); + return this; + } } export { Record }; diff --git a/src/Repository.ts b/src/Repository.ts index 62da225..2b5c40a 100644 --- a/src/Repository.ts +++ b/src/Repository.ts @@ -80,6 +80,8 @@ class Repository { public models: Record = {}; /** Number of queries emitted on the underlying knex instance (best-effort). */ public queryCount = 0; + /** Repository-level context storage. */ + private _context: Map = new Map(); /** * @param connection Knex connection or a minimal wrapper @@ -227,6 +229,10 @@ class Repository { : null; }, }); + // Inherit context from parent repository + for (const [key, value] of this._context.entries()) { + txRepo._context.set(key, value); + } let result; // Re-register models with the same metadata for (const name of Object.keys(this.models)) { @@ -276,6 +282,30 @@ class Repository { return this; } + /** + * Get a context value by key with an optional default value. + * @param {string} key - The context key + * @param {any} [defaultValue] - The default value if key not found + * @returns {any} The context value or default value + */ + get_context(key: string, defaultValue?: any): any { + if (this._context.has(key)) { + return this._context.get(key); + } + return defaultValue; + } + + /** + * Set a context value by key. + * @param {string} key - The context key + * @param {any} value - The value to set + * @returns {this} + */ + set_context(key: string, value: any): this { + this._context.set(key, value); + return this; + } + /** * Destroy the repository, flushing pending changes and closing the connection. */ diff --git a/tests/context.test.ts b/tests/context.test.ts new file mode 100644 index 0000000..f5eeaa0 --- /dev/null +++ b/tests/context.test.ts @@ -0,0 +1,279 @@ +// @ts-nocheck - Test file with implicit any types +import { Connection, Repository } from '..'; + +/** + * Test suite for repository-level context with get_context and set_context API + */ +describe('Repository Context', () => { + let conn; + let repo; + + beforeAll(async () => { + conn = new Connection({ client: 'sqlite3', connection: { filename: ':memory:' } }); + await conn.connect(); + repo = new Repository(conn); + + // Define a simple model for testing + class Users { + static _name = 'Users'; + static table = 'users'; + static fields = { + id: 'primary', + email: { type: 'string', required: true }, + name: 'string', + }; + } + + repo.register(Users); + await repo.sync({ force: true }); + }); + + afterAll(async () => { + await conn.destroy(); + }); + + describe('Repository-level context', () => { + test('can set and get context values', () => { + repo.set_context('tenant_id', 123); + repo.set_context('user_locale', 'en-US'); + + expect(repo.get_context('tenant_id')).toBe(123); + expect(repo.get_context('user_locale')).toBe('en-US'); + }); + + test('returns undefined for non-existent keys', () => { + expect(repo.get_context('non_existent_key')).toBeUndefined(); + }); + + test('returns default value for non-existent keys', () => { + expect(repo.get_context('non_existent_key', 'default')).toBe('default'); + expect(repo.get_context('another_key', 42)).toBe(42); + }); + + test('can overwrite existing context values', () => { + repo.set_context('counter', 1); + expect(repo.get_context('counter')).toBe(1); + + repo.set_context('counter', 2); + expect(repo.get_context('counter')).toBe(2); + }); + + test('supports various data types', () => { + repo.set_context('string_val', 'hello'); + repo.set_context('number_val', 42); + repo.set_context('bool_val', true); + repo.set_context('obj_val', { key: 'value' }); + repo.set_context('array_val', [1, 2, 3]); + repo.set_context('null_val', null); + + expect(repo.get_context('string_val')).toBe('hello'); + expect(repo.get_context('number_val')).toBe(42); + expect(repo.get_context('bool_val')).toBe(true); + expect(repo.get_context('obj_val')).toEqual({ key: 'value' }); + expect(repo.get_context('array_val')).toEqual([1, 2, 3]); + expect(repo.get_context('null_val')).toBeNull(); + }); + }); + + describe('Model-level context access', () => { + test('model can access repository context', () => { + repo.set_context('model_test_key', 'model_value'); + + const Users = repo.get('Users'); + expect(Users.get_context('model_test_key')).toBe('model_value'); + }); + + test('model can set repository context', () => { + const Users = repo.get('Users'); + Users.set_context('model_set_key', 'from_model'); + + expect(repo.get_context('model_set_key')).toBe('from_model'); + }); + + test('model context changes affect repository', () => { + repo.set_context('shared_key', 'initial'); + const Users = repo.get('Users'); + + Users.set_context('shared_key', 'updated'); + expect(repo.get_context('shared_key')).toBe('updated'); + }); + }); + + describe('Record-level context access', () => { + test('record can access repository context', async () => { + repo.set_context('record_test_key', 'record_value'); + + const Users = repo.get('Users'); + const user = await Users.create({ email: 'test1@example.com' }); + + expect(user.get_context('record_test_key')).toBe('record_value'); + }); + + test('record can set repository context', async () => { + const Users = repo.get('Users'); + const user = await Users.create({ email: 'test2@example.com' }); + + user.set_context('record_set_key', 'from_record'); + expect(repo.get_context('record_set_key')).toBe('from_record'); + }); + + test('record context changes affect repository and model', async () => { + repo.set_context('shared_record_key', 'initial'); + + const Users = repo.get('Users'); + const user = await Users.create({ email: 'test3@example.com' }); + + user.set_context('shared_record_key', 'updated_from_record'); + + expect(repo.get_context('shared_record_key')).toBe('updated_from_record'); + expect(Users.get_context('shared_record_key')).toBe('updated_from_record'); + }); + }); + + describe('Transaction context inheritance', () => { + test('transaction inherits parent context at creation', async () => { + repo.set_context('parent_key', 'parent_value'); + repo.set_context('shared_key', 'from_parent'); + + await repo.transaction(async (tx) => { + expect(tx.get_context('parent_key')).toBe('parent_value'); + expect(tx.get_context('shared_key')).toBe('from_parent'); + }); + }); + + test('transaction modifications are isolated from parent', async () => { + repo.set_context('isolation_test', 'parent_value'); + + await repo.transaction(async (tx) => { + tx.set_context('isolation_test', 'tx_value'); + tx.set_context('tx_only_key', 'tx_only_value'); + + expect(tx.get_context('isolation_test')).toBe('tx_value'); + expect(tx.get_context('tx_only_key')).toBe('tx_only_value'); + }); + + // Parent repository should be unchanged + expect(repo.get_context('isolation_test')).toBe('parent_value'); + expect(repo.get_context('tx_only_key')).toBeUndefined(); + }); + + test('model within transaction uses transaction context', async () => { + repo.set_context('tx_model_key', 'parent_value'); + + await repo.transaction(async (tx) => { + const Users = tx.get('Users'); + + expect(Users.get_context('tx_model_key')).toBe('parent_value'); + + Users.set_context('tx_model_key', 'tx_updated'); + expect(Users.get_context('tx_model_key')).toBe('tx_updated'); + expect(tx.get_context('tx_model_key')).toBe('tx_updated'); + }); + + // Parent should remain unchanged + expect(repo.get_context('tx_model_key')).toBe('parent_value'); + }); + + test('record within transaction uses transaction context', async () => { + repo.set_context('tx_record_key', 'parent_value'); + + await repo.transaction(async (tx) => { + const Users = tx.get('Users'); + const user = await Users.create({ email: 'tx_test@example.com' }); + + expect(user.get_context('tx_record_key')).toBe('parent_value'); + + user.set_context('tx_record_key', 'tx_record_updated'); + expect(user.get_context('tx_record_key')).toBe('tx_record_updated'); + expect(tx.get_context('tx_record_key')).toBe('tx_record_updated'); + }); + + // Parent should remain unchanged + expect(repo.get_context('tx_record_key')).toBe('parent_value'); + }); + + test.skip('nested transactions inherit from immediate parent (not supported in SQLite)', async () => { + // Note: SQLite doesn't support true nested transactions + // This test is skipped but documents expected behavior for databases that support it + repo.set_context('nested_key', 'root_value'); + + await repo.transaction(async (tx1) => { + tx1.set_context('nested_key', 'tx1_value'); + tx1.set_context('tx1_only', 'tx1_only_value'); + + await tx1.transaction(async (tx2) => { + // Should inherit from tx1 + expect(tx2.get_context('nested_key')).toBe('tx1_value'); + expect(tx2.get_context('tx1_only')).toBe('tx1_only_value'); + + // Modify in tx2 + tx2.set_context('nested_key', 'tx2_value'); + tx2.set_context('tx2_only', 'tx2_only_value'); + + expect(tx2.get_context('nested_key')).toBe('tx2_value'); + expect(tx2.get_context('tx2_only')).toBe('tx2_only_value'); + }); + + // tx1 should be unchanged + expect(tx1.get_context('nested_key')).toBe('tx1_value'); + expect(tx1.get_context('tx2_only')).toBeUndefined(); + }); + + // Root should be unchanged + expect(repo.get_context('nested_key')).toBe('root_value'); + }); + }); + + describe('Context use cases', () => { + test('multi-tenancy: set tenant_id in context', async () => { + repo.set_context('tenant_id', 'tenant-123'); + + await repo.transaction(async (tx) => { + const Users = tx.get('Users'); + const user = await Users.create({ + email: 'tenant-user@example.com', + name: 'Tenant User', + }); + + // Record can check tenant from context + const tenantId = user.get_context('tenant_id'); + expect(tenantId).toBe('tenant-123'); + }); + }); + + test('user context: store current user info', async () => { + repo.set_context('current_user_id', 999); + repo.set_context('current_user_role', 'admin'); + + const Users = repo.get('Users'); + const user = await Users.create({ + email: 'context-user@example.com', + }); + + expect(user.get_context('current_user_id')).toBe(999); + expect(user.get_context('current_user_role')).toBe('admin'); + }); + + test('feature flags: enable/disable features via context', () => { + repo.set_context('feature_new_ui', true); + repo.set_context('feature_beta_api', false); + + const Users = repo.get('Users'); + expect(Users.get_context('feature_new_ui')).toBe(true); + expect(Users.get_context('feature_beta_api')).toBe(false); + }); + + test('request metadata: store request id and timestamp', async () => { + const requestId = 'req-' + Date.now(); + const timestamp = new Date(); + + repo.set_context('request_id', requestId); + repo.set_context('request_timestamp', timestamp); + + await repo.transaction(async (tx) => { + expect(tx.get_context('request_id')).toBe(requestId); + expect(tx.get_context('request_timestamp')).toBe(timestamp); + }); + }); + }); +});