diff --git a/.changeset/implement-cache-manager.md b/.changeset/implement-cache-manager.md new file mode 100644 index 0000000..9981d6a --- /dev/null +++ b/.changeset/implement-cache-manager.md @@ -0,0 +1,13 @@ +--- +'lino-cache': minor +--- + +Implement cache-manager compatible interface with Links Notation storage + +- Add `LinoCache` class with full cache-manager store interface +- Support two storage modes: folder mode (separate files) and single-file mode +- Implement all cache-manager methods: `get`, `set`, `del`, `mget`, `mset`, `mdel`, `wrap`, `clear`, `reset`, `ttl`, `has`, `keys`, `disconnect` +- Add TTL (time-to-live) support for automatic entry expiration +- Use lino-objects-codec for serialization to Links Notation format +- Include comprehensive TypeScript type definitions +- Add factory functions: `createLinoCache` and `linoStore` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e49d457..a80095a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,7 +140,7 @@ jobs: - name: Run tests (Deno) if: matrix.runtime == 'deno' - run: deno test --allow-read + run: deno test --allow-read --allow-write # Release - only runs on main after tests pass (for push events) release: diff --git a/README.md b/README.md index 9a2dd92..8e46b3f 100644 --- a/README.md +++ b/README.md @@ -1,183 +1,280 @@ -# js-ai-driven-development-pipeline-template +# lino-cache -A comprehensive template for AI-driven JavaScript/TypeScript development with full CI/CD pipeline support. +A cache-manager compatible file-based cache using [Links Notation](https://github.com/link-foundation/links-notation) (.lino) format instead of JSON. + +[![Tests](https://github.com/link-foundation/lino-cache/actions/workflows/release.yml/badge.svg)](https://github.com/link-foundation/lino-cache/actions/workflows/release.yml) +[![npm version](https://img.shields.io/npm/v/lino-cache.svg)](https://www.npmjs.com/package/lino-cache) +[![License: Unlicense](https://img.shields.io/badge/license-Unlicense-blue.svg)](http://unlicense.org/) ## Features -- **Multi-runtime support**: Works with Node.js, Bun, and Deno -- **Universal testing**: Uses [test-anywhere](https://github.com/link-foundation/test-anywhere) for cross-runtime tests -- **Automated releases**: Changesets-based versioning with GitHub Actions -- **Code quality**: ESLint + Prettier with pre-commit hooks via Husky -- **Package manager agnostic**: Works with npm, yarn, bun, deno, and pnpm +- **cache-manager compatible** - Implements the full cache-manager store interface +- **Links Notation storage** - Uses [lino-objects-codec](https://github.com/link-foundation/lino-objects-codec) for serialization +- **Two storage modes**: + - **Folder mode** - Each cache key stored in a separate `.lino` file + - **Single-file mode** - All cache entries in one `.lino` file +- **TTL support** - Time-to-live for automatic expiration +- **Multi-runtime** - Works with Node.js, Bun, and Deno +- **TypeScript support** - Full type definitions included + +## Installation + +```bash +npm install lino-cache +``` ## Quick Start -### Using This Template +```javascript +import { LinoCache, createLinoCache, linoStore } from 'lino-cache'; -1. Click "Use this template" on GitHub to create a new repository -2. Clone your new repository -3. Update `package.json` with your package name and description -4. Update the `PACKAGE_NAME` constant in these scripts: - - `scripts/validate-changeset.mjs` - - `scripts/publish-to-npm.mjs` - - `scripts/format-release-notes.mjs` - - `scripts/create-manual-changeset.mjs` -5. Install dependencies: `npm install` -6. Start developing! +// Create a cache instance (folder mode by default) +const cache = new LinoCache({ + basePath: '.cache', + ttl: 60000, // Default TTL: 1 minute +}); -### Development +// Basic operations +await cache.set('user:1', { name: 'Alice', age: 30 }); +const user = await cache.get('user:1'); +console.log(user); // { name: 'Alice', age: 30 } -```bash -# Install dependencies -npm install +// Delete +await cache.del('user:1'); -# Run tests -npm test +// Check existence +const exists = await cache.has('user:1'); // false +``` -# Or with other runtimes: -bun test -deno test --allow-read +## Storage Modes -# Lint code -npm run lint +### Folder Mode (Default) -# Format code -npm run format +Each cache key is stored in a separate `.lino` file. Best for: -# Check all (lint + format + file size) -npm run check +- Large number of cache entries +- Independent access to cache entries +- When you need to inspect individual cached values + +```javascript +const cache = new LinoCache({ + mode: 'folder', // Default + basePath: '.cache', +}); + +await cache.set('key1', 'value1'); // Creates .cache/key1.lino +await cache.set('key2', 'value2'); // Creates .cache/key2.lino +``` + +### Single-File Mode + +All cache entries stored in one `.lino` file. Best for: + +- Small number of cache entries +- When you want all cache data in one file +- Simpler file management + +```javascript +const cache = new LinoCache({ + mode: 'file', + basePath: '.cache', + fileName: 'cache.lino', +}); + +await cache.set('key1', 'value1'); // Both stored in +await cache.set('key2', 'value2'); // .cache/cache.lino +``` + +## API Reference + +### Constructor Options + +```typescript +interface LinoCacheOptions { + ttl?: number; // Default TTL in milliseconds (0 = no expiration) + mode?: 'file' | 'folder'; // Storage mode (default: 'folder') + basePath?: string; // Cache directory (default: '.cache') + fileName?: string; // File name for single-file mode (default: 'cache.lino') +} +``` + +### Methods + +#### `set(key, value, [ttl])` + +Sets a value in the cache. + +```javascript +await cache.set('key', 'value'); +await cache.set('key', 'value', 5000); // With 5 second TTL +``` + +#### `get(key)` + +Gets a value from the cache. Returns `undefined` if not found or expired. + +```javascript +const value = await cache.get('key'); +``` + +#### `del(key)` + +Deletes a value from the cache. + +```javascript +const deleted = await cache.del('key'); // true if deleted +``` + +#### `has(key)` + +Checks if a key exists and is not expired. + +```javascript +const exists = await cache.has('key'); // boolean ``` -## Project Structure +#### `keys()` +Returns all non-expired keys. + +```javascript +const allKeys = await cache.keys(); // ['key1', 'key2', ...] ``` -. -├── .changeset/ # Changeset configuration -├── .github/workflows/ # GitHub Actions CI/CD -├── .husky/ # Git hooks (pre-commit) -├── examples/ # Usage examples -├── scripts/ # Build and release scripts -├── src/ # Source code -│ ├── index.js # Main entry point -│ └── index.d.ts # TypeScript definitions -├── tests/ # Test files -├── .eslintrc.js # ESLint configuration -├── .prettierrc # Prettier configuration -├── bunfig.toml # Bun configuration -├── deno.json # Deno configuration -└── package.json # Node.js package manifest + +#### `clear()` / `reset()` + +Clears all values from the cache. + +```javascript +await cache.clear(); ``` -## Design Choices +#### `mset(entries)` -### Multi-Runtime Support +Sets multiple values at once. -This template is designed to work seamlessly with all major JavaScript runtimes: +```javascript +await cache.mset([ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: 'value2', ttl: 5000 }, +]); +``` -- **Node.js**: Primary runtime, uses built-in test runner (`node --test`) -- **Bun**: Fast alternative runtime with native test support (`bun test`) -- **Deno**: Secure runtime with built-in TypeScript support (`deno test`) +#### `mget(keys)` -The [test-anywhere](https://github.com/link-foundation/test-anywhere) framework provides a unified testing API that works identically across all runtimes. +Gets multiple values at once. -### Package Manager Agnostic +```javascript +const values = await cache.mget(['key1', 'key2', 'key3']); +// [value1, value2, undefined] +``` + +#### `mdel(keys)` -While `package.json` is the source of truth for dependencies, the template supports: +Deletes multiple values at once. + +```javascript +const deleted = await cache.mdel(['key1', 'key2']); +``` -- **npm**: Default, generates `package-lock.json` -- **yarn**: Uses `yarn.lock` -- **bun**: Uses `bun.lockb` -- **pnpm**: Uses `pnpm-lock.yaml` -- **deno**: Uses `deno.json` for configuration +#### `wrap(key, fn, [ttl])` -Note: `package-lock.json` is not committed by default to allow any package manager. +Wraps a function with caching. Returns cached value if available, otherwise executes the function and caches the result. -### Code Quality +```javascript +const data = await cache.wrap( + 'expensive-operation', + async () => { + // This only runs if not cached + return await fetchExpensiveData(); + }, + 60000 +); +``` -- **ESLint**: Configured with recommended rules + Prettier integration -- **Prettier**: Consistent code formatting -- **Husky + lint-staged**: Pre-commit hooks ensure code quality -- **File size limit**: Scripts must stay under 1000 lines for maintainability +#### `ttl(key)` -### Release Workflow +Gets the remaining TTL for a key in milliseconds. -The release workflow uses [Changesets](https://github.com/changesets/changesets) for version management: +```javascript +const remaining = await cache.ttl('key'); +// > 0: remaining time +// -1: no TTL set +// -2: key not found +``` -1. **Creating a changeset**: Run `npm run changeset` to document changes -2. **PR validation**: CI checks for valid changeset in each PR -3. **Automated versioning**: Merging to `main` triggers version bump -4. **npm publishing**: Automated via OIDC trusted publishing (no tokens needed) -5. **GitHub releases**: Auto-created with formatted release notes +#### `disconnect()` -#### Manual Releases +Closes the cache and releases resources. -Two manual release modes are available via GitHub Actions: +```javascript +await cache.disconnect(); +``` -- **Instant release**: Immediately bump version and publish -- **Changeset PR**: Create a PR with changeset for review +## Factory Functions -### CI/CD Pipeline +### `createLinoCache(options)` -The GitHub Actions workflow (`.github/workflows/release.yml`) provides: +Creates a new LinoCache instance. -1. **Changeset check**: Validates PR has exactly one changeset -2. **Lint & format**: Ensures code quality standards -3. **Test matrix**: 3 runtimes × 3 OS = 9 test combinations -4. **Release**: Automated versioning and npm publishing +```javascript +const cache = createLinoCache({ basePath: '.cache' }); +``` -## Configuration +### `linoStore(options)` -### Updating Package Name +Creates a cache-manager compatible store. -After creating a repository from this template, update the package name in: +```javascript +import { caching } from 'cache-manager'; +import { linoStore } from 'lino-cache'; -1. `package.json`: `"name": "your-package-name"` -2. `.changeset/config.json`: Package references -3. Scripts that reference the package name (see Quick Start) +const cache = await caching( + linoStore({ + basePath: '.cache', + ttl: 60000, + }) +); +``` -### ESLint Rules +## Why Links Notation? -Customize ESLint in `eslint.config.js`. Current configuration: +Links Notation (.lino) is a human-readable serialization format that: -- ES Modules support -- Prettier integration -- No console restrictions (common in CLI tools) -- Strict equality enforcement -- Async/await best practices -- **Strict unused variables rule**: No exceptions - all unused variables, arguments, and caught errors must be removed (no `_` prefix exceptions) +- Supports circular references natively +- Preserves object identity +- Is more compact for certain data structures +- Provides better debugging experience -### Prettier Options +Learn more: [Links Notation](https://github.com/link-foundation/links-notation) -Configured in `.prettierrc`: +## Development -- Single quotes -- Semicolons -- 2-space indentation -- 80-character line width -- ES5 trailing commas -- LF line endings +```bash +# Install dependencies +npm install -## Scripts Reference +# Run tests +npm test -| Script | Description | -| ---------------------- | --------------------------------------- | -| `npm test` | Run tests with Node.js | -| `npm run lint` | Check code with ESLint | -| `npm run lint:fix` | Fix ESLint issues automatically | -| `npm run format` | Format code with Prettier | -| `npm run format:check` | Check formatting without changing files | -| `npm run check` | Run all checks (lint + format) | -| `npm run changeset` | Create a new changeset | +# Lint code +npm run lint -## Contributing +# Format code +npm run format -1. Fork the repository -2. Create a feature branch: `git checkout -b feature/my-feature` -3. Make your changes -4. Create a changeset: `npm run changeset` -5. Commit your changes (pre-commit hooks will run automatically) -6. Push and create a Pull Request +# Run all checks +npm run check +``` ## License [Unlicense](LICENSE) - Public Domain + +## Links + +- [GitHub Repository](https://github.com/link-foundation/lino-cache) +- [npm Package](https://www.npmjs.com/package/lino-cache) +- [lino-objects-codec](https://github.com/link-foundation/lino-objects-codec) +- [Links Notation](https://github.com/link-foundation/links-notation) +- [cache-manager](https://www.npmjs.com/package/cache-manager) diff --git a/examples/basic-usage.js b/examples/basic-usage.js index 755ce81..6f33fbe 100644 --- a/examples/basic-usage.js +++ b/examples/basic-usage.js @@ -1,27 +1,181 @@ /** - * Basic usage example - * Demonstrates how to use the package + * Basic usage examples for lino-cache * - * Run with any runtime: - * - Node.js: node examples/basic-usage.js - * - Bun: bun examples/basic-usage.js - * - Deno: deno run examples/basic-usage.js + * Run with: node examples/basic-usage.js */ -import { add, multiply, delay } from '../src/index.js'; +import { LinoCache, createLinoCache } from '../src/index.js'; +import { promises as fs } from 'node:fs'; -// Example: Using add function -console.log('Addition examples:'); -console.log(` 2 + 3 = ${add(2, 3)}`); -console.log(` -1 + 5 = ${add(-1, 5)}`); +// Helper to delay execution +const delay = (ms) => + new Promise((resolve) => globalThis.setTimeout(resolve, ms)); -// Example: Using multiply function -console.log('\nMultiplication examples:'); -console.log(` 4 * 5 = ${multiply(4, 5)}`); -console.log(` -2 * 3 = ${multiply(-2, 3)}`); +// Cleanup helper +const cleanup = async (dir) => { + try { + await fs.rm(dir, { recursive: true, force: true }); + } catch { + // Ignore + } +}; -// Example: Using async delay function -console.log('\nAsync example:'); -console.log(' Waiting 100ms...'); -await delay(100); -console.log(' Done!'); +// Example 1: Folder Mode (default) +async function exampleFolderMode() { + console.log('1. Folder Mode (each key in separate file)'); + console.log('-'.repeat(50)); + + const folderCache = new LinoCache({ + basePath: '.cache-examples/folder', + }); + + await folderCache.set('user:1', { name: 'Alice', age: 30 }); + await folderCache.set('user:2', { name: 'Bob', age: 25 }); + + const user1 = await folderCache.get('user:1'); + console.log('user:1 =', user1); + + const keys = await folderCache.keys(); + console.log('All keys:', keys); + + const files = await fs.readdir('.cache-examples/folder'); + console.log('Files created:', files); + console.log(); +} + +// Example 2: Single-File Mode +async function exampleSingleFileMode() { + console.log('2. Single-File Mode (all keys in one file)'); + console.log('-'.repeat(50)); + + const fileCache = new LinoCache({ + mode: 'file', + basePath: '.cache-examples/single', + fileName: 'app-cache.lino', + }); + + await fileCache.set('config', { theme: 'dark', lang: 'en' }); + await fileCache.set('session', { token: 'abc123', userId: 1 }); + + const config = await fileCache.get('config'); + console.log('config =', config); + + const singleFiles = await fs.readdir('.cache-examples/single'); + console.log('Files created:', singleFiles); + console.log(); +} + +// Example 3: TTL (Time To Live) +async function exampleTTL() { + console.log('3. TTL (Time To Live)'); + console.log('-'.repeat(50)); + + const ttlCache = new LinoCache({ + basePath: '.cache-examples/ttl', + }); + + await ttlCache.set('temporary', 'this will expire', 500); + console.log('Set "temporary" with 500ms TTL'); + + let value = await ttlCache.get('temporary'); + console.log('Immediately after set:', value); + + const remaining = await ttlCache.ttl('temporary'); + console.log('Remaining TTL:', remaining, 'ms'); + + console.log('Waiting 600ms...'); + await delay(600); + + value = await ttlCache.get('temporary'); + console.log('After 600ms:', value); + console.log(); +} + +// Example 4: wrap() function +async function exampleWrap() { + console.log('4. wrap() - Cache function results'); + console.log('-'.repeat(50)); + + const wrapCache = new LinoCache({ + basePath: '.cache-examples/wrap', + }); + + let callCount = 0; + const expensiveOperation = async () => { + callCount++; + console.log(` [expensiveOperation called - count: ${callCount}]`); + await delay(100); + return { result: 'computed value', timestamp: Date.now() }; + }; + + console.log('First call (computes):'); + const result1 = await wrapCache.wrap('expensive', expensiveOperation); + console.log('Result:', result1); + + console.log('\nSecond call (from cache):'); + const result2 = await wrapCache.wrap('expensive', expensiveOperation); + console.log('Result:', result2); + + console.log(`\nTotal function calls: ${callCount} (should be 1)`); + console.log(); +} + +// Example 5: Multi-key operations +async function exampleMultiKey() { + console.log('5. Multi-key Operations'); + console.log('-'.repeat(50)); + + const multiCache = new LinoCache({ + basePath: '.cache-examples/multi', + }); + + await multiCache.mset([ + { key: 'product:1', value: { name: 'Widget', price: 9.99 } }, + { key: 'product:2', value: { name: 'Gadget', price: 19.99 } }, + { key: 'product:3', value: { name: 'Doohickey', price: 29.99 } }, + ]); + console.log('Set 3 products with mset()'); + + const products = await multiCache.mget(['product:1', 'product:2', 'missing']); + console.log('mget() results:', products); + + await multiCache.mdel(['product:1', 'product:2']); + console.log('Deleted product:1 and product:2'); + + const remainingKeys = await multiCache.keys(); + console.log('Remaining keys:', remainingKeys); + console.log(); +} + +// Example 6: Factory function +async function exampleFactory() { + console.log('6. Factory Function'); + console.log('-'.repeat(50)); + + const factoryCache = createLinoCache({ + basePath: '.cache-examples/factory', + ttl: 60000, + }); + + await factoryCache.set('quick', 'easy setup'); + console.log('Created cache with createLinoCache()'); + console.log('Value:', await factoryCache.get('quick')); + console.log(); +} + +async function main() { + console.log('=== lino-cache Basic Usage Examples ===\n'); + + await exampleFolderMode(); + await exampleSingleFileMode(); + await exampleTTL(); + await exampleWrap(); + await exampleMultiKey(); + await exampleFactory(); + + console.log('Cleaning up example caches...'); + await cleanup('.cache-examples'); + console.log('Done!'); +} + +main().catch(console.error); diff --git a/package-lock.json b/package-lock.json index e699328..918222c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { - "name": "my-package", - "version": "0.1.4", + "name": "lino-cache", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "my-package", - "version": "0.1.4", + "name": "lino-cache", + "version": "0.1.0", "license": "Unlicense", + "dependencies": { + "lino-objects-codec": "^0.1.1" + }, "devDependencies": { "@changesets/cli": "^2.29.7", "eslint": "^9.38.0", @@ -2631,6 +2634,24 @@ "node": ">= 0.8.0" } }, + "node_modules/links-notation": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/links-notation/-/links-notation-0.11.2.tgz", + "integrity": "sha512-VPyELWBXpaCCiNPVeZhMbG7RuvOQR51nhqELK+s/rbSzKYhSs+tyiSOdQ7z8I7Kh3PLABF3bZETtWSFwx3vFfg==", + "license": "Unlicense" + }, + "node_modules/lino-objects-codec": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/lino-objects-codec/-/lino-objects-codec-0.1.1.tgz", + "integrity": "sha512-URX1MAhHyVga5EkpUqDTMkX1D+HYBW3spgKh6vCneQnzOMLn09XooQOuBy0apbfwlqY5qHt2fpvRdT75dBn2Qw==", + "license": "Unlicense", + "dependencies": { + "links-notation": "^0.11.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/lint-staged": { "version": "16.2.7", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", diff --git a/package.json b/package.json index 32578a9..586099e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "my-package", - "version": "0.1.4", - "description": "A JavaScript/TypeScript package template for AI-driven development", + "name": "lino-cache", + "version": "0.1.0", + "description": "A cache-manager compatible file-based cache using Links Notation (.lino) format", "type": "module", "main": "src/index.js", "types": "src/index.d.ts", @@ -26,20 +26,26 @@ "changeset:status": "changeset status --since=origin/main" }, "keywords": [ - "template", - "javascript", - "typescript", - "ai-driven" + "cache", + "cache-manager", + "lino", + "links-notation", + "file-cache", + "persistent-cache", + "storage" ], "author": "", "license": "Unlicense", "repository": { "type": "git", - "url": "https://github.com/link-foundation/js-ai-driven-development-pipeline-template" + "url": "https://github.com/link-foundation/lino-cache" }, "engines": { "node": ">=20.0.0" }, + "dependencies": { + "lino-objects-codec": "^0.1.1" + }, "devDependencies": { "@changesets/cli": "^2.29.7", "eslint": "^9.38.0", diff --git a/scripts/create-manual-changeset.mjs b/scripts/create-manual-changeset.mjs index 37dfe15..f9e59a1 100644 --- a/scripts/create-manual-changeset.mjs +++ b/scripts/create-manual-changeset.mjs @@ -16,7 +16,7 @@ import { writeFileSync } from 'fs'; import { randomBytes } from 'crypto'; // TODO: Update this to match your package name in package.json -const PACKAGE_NAME = 'my-package'; +const PACKAGE_NAME = 'lino-cache'; // Load use-m dynamically const { use } = eval( diff --git a/scripts/format-release-notes.mjs b/scripts/format-release-notes.mjs index c6b6aaa..476118f 100644 --- a/scripts/format-release-notes.mjs +++ b/scripts/format-release-notes.mjs @@ -24,7 +24,7 @@ */ // TODO: Update this to match your package name in package.json -const PACKAGE_NAME = 'my-package'; +const PACKAGE_NAME = 'lino-cache'; // Load use-m dynamically const { use } = eval( diff --git a/scripts/publish-to-npm.mjs b/scripts/publish-to-npm.mjs index a7c664e..fd05f3e 100644 --- a/scripts/publish-to-npm.mjs +++ b/scripts/publish-to-npm.mjs @@ -16,7 +16,7 @@ import { readFileSync, appendFileSync } from 'fs'; // TODO: Update this to match your package name in package.json -const PACKAGE_NAME = 'my-package'; +const PACKAGE_NAME = 'lino-cache'; // Load use-m dynamically const { use } = eval( diff --git a/scripts/validate-changeset.mjs b/scripts/validate-changeset.mjs index 46206a0..d950f48 100644 --- a/scripts/validate-changeset.mjs +++ b/scripts/validate-changeset.mjs @@ -10,7 +10,7 @@ import { readdirSync, readFileSync } from 'fs'; import { join } from 'path'; // TODO: Update this to match your package name in package.json -const PACKAGE_NAME = 'my-package'; +const PACKAGE_NAME = 'lino-cache'; try { // Count changeset files (excluding README.md and config.json) diff --git a/src/index.d.ts b/src/index.d.ts index 658cd82..4a8a1b2 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,27 +1,239 @@ /** - * Example module type definitions - * Replace this with your actual type definitions + * lino-cache - A cache-manager compatible file-based cache using Links Notation + * + * Type definitions for lino-cache */ /** - * Adds two numbers - * @param a - First number - * @param b - Second number - * @returns Sum of a and b + * Cache storage mode + * - 'file': All cache entries stored in a single .lino file + * - 'folder': Each cache entry stored in a separate .lino file */ -export declare const add: (a: number, b: number) => number; +export type CacheMode = 'file' | 'folder'; /** - * Multiplies two numbers - * @param a - First number - * @param b - Second number - * @returns Product of a and b + * Configuration options for LinoCache */ -export declare const multiply: (a: number, b: number) => number; +export interface LinoCacheOptions { + /** + * Default TTL (time-to-live) in milliseconds + * Set to 0 for no expiration + * @default 0 + */ + ttl?: number; + + /** + * Cache storage mode + * @default 'folder' + */ + mode?: CacheMode; + + /** + * Base path for cache storage directory + * @default '.cache' + */ + basePath?: string; + + /** + * File name for single-file mode cache + * Only used when mode is 'file' + * @default 'cache.lino' + */ + fileName?: string; +} + +/** + * Cache entry for mset operation + */ +export interface MsetEntry { + /** + * Cache key + */ + key: string; + + /** + * Value to cache + */ + value: T; + + /** + * Optional TTL for this specific entry (in milliseconds) + */ + ttl?: number; +} + +/** + * LinoCache - A cache-manager compatible store using Links Notation + * + * This class provides a file-based caching solution that stores data + * in Links Notation (.lino) format instead of JSON. It supports both + * single-file mode (all cache in one file) and folder mode (each key + * in a separate file). + * + * The class implements the cache-manager store interface, making it + * compatible with the cache-manager library. + * + * @example + * ```typescript + * import { LinoCache } from 'lino-cache'; + * + * // Folder mode (default) + * const folderCache = new LinoCache({ + * basePath: '.cache/folder' + * }); + * + * // Single-file mode + * const fileCache = new LinoCache({ + * mode: 'file', + * basePath: '.cache', + * fileName: 'my-cache.lino' + * }); + * + * await folderCache.set('user:1', { name: 'Alice', age: 30 }); + * const user = await folderCache.get('user:1'); + * ``` + */ +export declare class LinoCache { + /** + * Creates a new LinoCache instance + * @param options - Cache configuration options + */ + constructor(options?: LinoCacheOptions); + + /** + * Sets a value in the cache + * @param key - The cache key + * @param value - The value to cache + * @param ttl - Optional TTL in milliseconds (overrides default) + * @returns The cached value + */ + set(key: string, value: T, ttl?: number): Promise; + + /** + * Gets a value from the cache + * @param key - The cache key + * @returns The cached value or undefined if not found/expired + */ + get(key: string): Promise; + + /** + * Deletes a value from the cache + * @param key - The cache key + * @returns True if the key was deleted, false if it didn't exist + */ + del(key: string): Promise; + + /** + * Sets multiple values in the cache + * @param entries - Array of key-value-ttl entries + * @returns Always returns true + */ + mset(entries: MsetEntry[]): Promise; + + /** + * Gets multiple values from the cache + * @param keys - Array of cache keys + * @returns Array of values (undefined for missing/expired keys) + */ + mget(keys: string[]): Promise<(T | undefined)[]>; + + /** + * Deletes multiple values from the cache + * @param keys - Array of cache keys + * @returns True if any keys were deleted + */ + mdel(keys: string[]): Promise; + + /** + * Clears all values from the cache + */ + clear(): Promise; + + /** + * Wraps a function with caching + * + * If the key exists in cache and is not expired, returns the cached value. + * Otherwise, executes the function, caches the result, and returns it. + * + * @param key - The cache key + * @param fn - The function to wrap + * @param ttl - Optional TTL in milliseconds + * @returns The cached or computed value + */ + wrap(key: string, fn: () => Promise | T, ttl?: number): Promise; + + /** + * Gets the remaining TTL for a key in milliseconds + * @param key - The cache key + * @returns Remaining TTL in ms, -1 if no TTL set, -2 if key not found + */ + ttl(key: string): Promise; + + /** + * Checks if a key exists in the cache and is not expired + * @param key - The cache key + * @returns True if the key exists and is not expired + */ + has(key: string): Promise; + + /** + * Returns all keys in the cache (excluding expired entries) + * @returns Array of cache keys + */ + keys(): Promise; + + /** + * Closes the cache and releases resources + */ + disconnect(): Promise; + + /** + * Alias for clear() - for cache-manager compatibility + */ + reset(): Promise; +} + +/** + * Creates a new LinoCache store + * @param options - Cache configuration options + * @returns A new LinoCache instance + * + * @example + * ```typescript + * import { createLinoCache } from 'lino-cache'; + * + * const cache = createLinoCache({ + * mode: 'folder', + * basePath: '.cache', + * ttl: 60000 // 1 minute default TTL + * }); + * ``` + */ +export declare function createLinoCache(options?: LinoCacheOptions): LinoCache; + +/** + * Creates a cache-manager compatible store + * + * This function creates a LinoCache instance that is compatible with + * the cache-manager library's store interface. + * + * @param options - Cache configuration options + * @returns A new LinoCache instance compatible with cache-manager + * + * @example + * ```typescript + * import { caching } from 'cache-manager'; + * import { linoStore } from 'lino-cache'; + * + * const cache = await caching(linoStore({ + * mode: 'folder', + * basePath: '.cache' + * })); + * ``` + */ +export declare function linoStore(options?: LinoCacheOptions): LinoCache; /** - * Delays execution for specified milliseconds - * @param ms - Milliseconds to wait - * @returns Promise that resolves after the delay + * Default export - LinoCache class */ -export declare const delay: (ms: number) => Promise; +export default LinoCache; diff --git a/src/index.js b/src/index.js index 22705fd..5d8b09c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,28 +1,478 @@ /** - * Example module entry point - * Replace this with your actual implementation + * lino-cache - A cache-manager compatible file-based cache using Links Notation + * + * Supports two modes: + * - Single-file mode: All cache entries stored in one .lino file + * - Folder mode: Each cache entry stored in a separate .lino file */ +import { encode, decode } from 'lino-objects-codec'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +/** + * Default configuration for LinoCache + */ +const DEFAULT_OPTIONS = { + ttl: 0, // 0 means no expiration + mode: 'folder', // 'file' or 'folder' + basePath: '.cache', + fileName: 'cache.lino', // Only used in 'file' mode +}; + +/** + * Internal cache entry structure + * @typedef {Object} CacheEntry + * @property {*} value - The cached value + * @property {number} expiresAt - Timestamp when entry expires (0 = never) + */ + +/** + * LinoCache options + * @typedef {Object} LinoCacheOptions + * @property {number} [ttl=0] - Default TTL in milliseconds (0 = no expiration) + * @property {'file'|'folder'} [mode='folder'] - Cache storage mode + * @property {string} [basePath='.cache'] - Base path for cache storage + * @property {string} [fileName='cache.lino'] - File name for single-file mode + */ + +/** + * Sanitizes a cache key to be safe for use as a filename + * @param {string} key - The cache key + * @returns {string} - A sanitized filename-safe version + */ +const sanitizeKeyForFilename = (key) => { + // Replace unsafe characters with safe alternatives + // Control characters intentionally matched to filter unsafe filename characters + const regex = /[<>:"/\\|?*\x00-\x1f]/g; // eslint-disable-line no-control-regex + const sanitized = key + .replace(regex, '_') + .replace(/\s+/g, '_') + .replace(/\.+/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); + + // If the sanitized result is empty, use a hash + if (!sanitized) { + return `key_${Buffer.from(key).toString('base64url')}`; + } + + // Limit filename length (leaving room for .lino extension) + if (sanitized.length > 200) { + const hash = Buffer.from(key).toString('base64url').slice(0, 32); + return `${sanitized.slice(0, 160)}_${hash}`; + } + + return sanitized; +}; + /** - * Example function that adds two numbers - * @param {number} a - First number - * @param {number} b - Second number - * @returns {number} Sum of a and b + * LinoCache - A cache-manager compatible store using Links Notation */ -export const add = (a, b) => a + b; +export class LinoCache { + /** + * @param {LinoCacheOptions} options - Cache configuration options + */ + constructor(options = {}) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.initialized = false; + this.singleFileCache = null; // Used in 'file' mode + } + + /** + * Ensures the cache directory exists + * @private + */ + async ensureInitialized() { + if (this.initialized) { + return; + } + + await fs.mkdir(this.options.basePath, { recursive: true }); + + if (this.options.mode === 'file') { + await this.loadSingleFileCache(); + } + + this.initialized = true; + } + + /** + * Gets the file path for a cache key in folder mode + * @private + * @param {string} key - The cache key + * @returns {string} - The file path + */ + getKeyFilePath(key) { + const safeKey = sanitizeKeyForFilename(key); + return path.join(this.options.basePath, `${safeKey}.lino`); + } + + /** + * Gets the file path for single-file mode + * @private + * @returns {string} - The file path + */ + getSingleFilePath() { + return path.join(this.options.basePath, this.options.fileName); + } + + /** + * Loads the cache from single file + * @private + */ + async loadSingleFileCache() { + const filePath = this.getSingleFilePath(); + try { + const content = await fs.readFile(filePath, 'utf-8'); + const decoded = decode({ notation: content }); + this.singleFileCache = decoded || {}; + } catch { + // File doesn't exist or is corrupted, start fresh + this.singleFileCache = {}; + } + } + + /** + * Saves the cache to single file + * @private + */ + async saveSingleFileCache() { + const filePath = this.getSingleFilePath(); + const encoded = encode({ obj: this.singleFileCache }); + await fs.writeFile(filePath, encoded, 'utf-8'); + } + + /** + * Checks if an entry is expired + * @private + * @param {CacheEntry} entry - The cache entry + * @returns {boolean} - True if expired + */ + isExpired(entry) { + if (!entry || !entry.expiresAt) { + return false; + } + return entry.expiresAt > 0 && Date.now() > entry.expiresAt; + } + + /** + * Calculates expiration timestamp + * @private + * @param {number} [ttl] - TTL in milliseconds + * @returns {number} - Expiration timestamp (0 = never) + */ + calculateExpiresAt(ttl) { + const effectiveTtl = ttl !== undefined ? ttl : this.options.ttl; + if (!effectiveTtl || effectiveTtl <= 0) { + return 0; + } + return Date.now() + effectiveTtl; + } + + /** + * Sets a value in the cache + * @param {string} key - The cache key + * @param {*} value - The value to cache + * @param {number} [ttl] - Optional TTL in milliseconds + * @returns {Promise<*>} - The cached value + */ + async set(key, value, ttl) { + await this.ensureInitialized(); + + const entry = { + value, + expiresAt: this.calculateExpiresAt(ttl), + }; + + if (this.options.mode === 'file') { + this.singleFileCache[key] = entry; + await this.saveSingleFileCache(); + } else { + const filePath = this.getKeyFilePath(key); + // Store original key for reverse lookup + const data = { key, ...entry }; + const encoded = encode({ obj: data }); + await fs.writeFile(filePath, encoded, 'utf-8'); + } + + return value; + } + + /** + * Gets a value from the cache + * @param {string} key - The cache key + * @returns {Promise<*>} - The cached value or undefined if not found/expired + */ + async get(key) { + await this.ensureInitialized(); + + let entry; + + if (this.options.mode === 'file') { + entry = this.singleFileCache[key]; + } else { + const filePath = this.getKeyFilePath(key); + try { + const content = await fs.readFile(filePath, 'utf-8'); + entry = decode({ notation: content }); + } catch { + return undefined; + } + } + + if (!entry) { + return undefined; + } + + if (this.isExpired(entry)) { + await this.del(key); + return undefined; + } + + return entry.value; + } + + /** + * Deletes a value from the cache + * @param {string} key - The cache key + * @returns {Promise} - True if deleted + */ + async del(key) { + await this.ensureInitialized(); + + if (this.options.mode === 'file') { + if (this.singleFileCache[key] !== undefined) { + delete this.singleFileCache[key]; + await this.saveSingleFileCache(); + return true; + } + return false; + } + + const filePath = this.getKeyFilePath(key); + try { + await fs.unlink(filePath); + return true; + } catch { + return false; + } + } + + /** + * Sets multiple values in the cache + * @param {Array<{key: string, value: *, ttl?: number}>} entries - Array of entries + * @returns {Promise} - Always returns true + */ + async mset(entries) { + await this.ensureInitialized(); + + for (const { key, value, ttl } of entries) { + await this.set(key, value, ttl); + } + + return true; + } + + /** + * Gets multiple values from the cache + * @param {string[]} keys - Array of cache keys + * @returns {Promise>} - Array of values (undefined for missing/expired) + */ + async mget(keys) { + await this.ensureInitialized(); + + const results = []; + for (const key of keys) { + const value = await this.get(key); + results.push(value); + } + return results; + } + + /** + * Deletes multiple values from the cache + * @param {string[]} keys - Array of cache keys + * @returns {Promise} - True if any were deleted + */ + async mdel(keys) { + await this.ensureInitialized(); + + let anyDeleted = false; + for (const key of keys) { + const deleted = await this.del(key); + if (deleted) { + anyDeleted = true; + } + } + return anyDeleted; + } + + /** + * Clears all values from the cache + * @returns {Promise} + */ + async clear() { + await this.ensureInitialized(); + + if (this.options.mode === 'file') { + this.singleFileCache = {}; + await this.saveSingleFileCache(); + } else { + // In folder mode, remove all .lino files + try { + const files = await fs.readdir(this.options.basePath); + for (const file of files) { + if (file.endsWith('.lino')) { + await fs.unlink(path.join(this.options.basePath, file)); + } + } + } catch { + // Directory might not exist, ignore + } + } + } + + /** + * Wraps a function with caching + * @param {string} key - The cache key + * @param {Function} fn - The function to wrap + * @param {number} [ttl] - Optional TTL in milliseconds + * @returns {Promise<*>} - The cached or computed value + */ + async wrap(key, fn, ttl) { + const cached = await this.get(key); + if (cached !== undefined) { + return cached; + } + + const value = await fn(); + await this.set(key, value, ttl); + return value; + } + + /** + * Gets the remaining TTL for a key in milliseconds + * @param {string} key - The cache key + * @returns {Promise} - Remaining TTL in ms, -1 if no TTL, -2 if not found + */ + async ttl(key) { + await this.ensureInitialized(); + + let entry; + + if (this.options.mode === 'file') { + entry = this.singleFileCache[key]; + } else { + const filePath = this.getKeyFilePath(key); + try { + const content = await fs.readFile(filePath, 'utf-8'); + entry = decode({ notation: content }); + } catch { + return -2; + } + } + + if (!entry) { + return -2; + } + + if (this.isExpired(entry)) { + return -2; + } + + if (!entry.expiresAt || entry.expiresAt === 0) { + return -1; + } + + return Math.max(0, entry.expiresAt - Date.now()); + } + + /** + * Checks if a key exists in the cache + * @param {string} key - The cache key + * @returns {Promise} - True if exists and not expired + */ + async has(key) { + const value = await this.get(key); + return value !== undefined; + } + + /** + * Returns all keys in the cache + * @returns {Promise} - Array of cache keys + */ + async keys() { + await this.ensureInitialized(); + + if (this.options.mode === 'file') { + const keys = []; + for (const key of Object.keys(this.singleFileCache)) { + const entry = this.singleFileCache[key]; + if (!this.isExpired(entry)) { + keys.push(key); + } + } + return keys; + } + + // In folder mode, read all .lino files + const keys = []; + try { + const files = await fs.readdir(this.options.basePath); + for (const file of files) { + if (file.endsWith('.lino')) { + const filePath = path.join(this.options.basePath, file); + try { + const content = await fs.readFile(filePath, 'utf-8'); + const entry = decode({ notation: content }); + if (entry && entry.key && !this.isExpired(entry)) { + keys.push(entry.key); + } + } catch { + // Skip corrupted files + } + } + } + } catch { + // Directory might not exist + } + return keys; + } + + /** + * Closes the cache (cleanup) + * @returns {Promise} + */ + disconnect() { + this.initialized = false; + this.singleFileCache = null; + return Promise.resolve(); + } + + /** + * Alias for clear() for cache-manager compatibility + * @returns {Promise} + */ + reset() { + return this.clear(); + } +} /** - * Example function that multiplies two numbers - * @param {number} a - First number - * @param {number} b - Second number - * @returns {number} Product of a and b + * Creates a new LinoCache store + * @param {LinoCacheOptions} [options] - Cache configuration options + * @returns {LinoCache} - A new LinoCache instance */ -export const multiply = (a, b) => a * b; +export const createLinoCache = (options = {}) => new LinoCache(options); /** - * Example async function - * @param {number} ms - Milliseconds to wait - * @returns {Promise} + * Creates a cache-manager compatible store + * This is for integration with the cache-manager library + * @param {LinoCacheOptions} [options] - Cache configuration options + * @returns {LinoCache} - A new LinoCache instance compatible with cache-manager */ -export const delay = (ms) => - new Promise((resolve) => globalThis.setTimeout(resolve, ms)); +export const linoStore = (options = {}) => new LinoCache(options); + +// Default export +export default LinoCache; diff --git a/tests/index.test.js b/tests/index.test.js index 15fbcb6..7e2273a 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -1,35 +1,450 @@ /** - * Example test file using test-anywhere + * Tests for lino-cache * Works with Node.js, Bun, and Deno */ -import { describe, it, expect } from 'test-anywhere'; -import { add, multiply } from '../src/index.js'; +import { describe, it, expect, beforeEach, afterEach } from 'test-anywhere'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { LinoCache, createLinoCache, linoStore } from '../src/index.js'; -describe('add function', () => { - it('should add two positive numbers', () => { - expect(add(2, 3)).toBe(5); +// Test directory for cache files +const TEST_CACHE_DIR = '.test-cache'; + +// Helper to clean up test directories +const cleanup = async (dir) => { + try { + await fs.rm(dir, { recursive: true, force: true }); + } catch { + // Ignore errors if directory doesn't exist + } +}; + +// Helper to delay for TTL testing +const delay = (ms) => + new Promise((resolve) => globalThis.setTimeout(resolve, ms)); + +describe('LinoCache - Folder Mode', () => { + const cacheDir = path.join(TEST_CACHE_DIR, 'folder'); + + beforeEach(async () => { + await cleanup(cacheDir); + }); + + afterEach(async () => { + await cleanup(cacheDir); + }); + + it('should set and get a value', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('key1', 'value1'); + const result = await cache.get('key1'); + expect(result).toBe('value1'); + }); + + it('should store complex objects', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + const obj = { + name: 'Alice', + age: 30, + nested: { active: true, tags: ['a', 'b', 'c'] }, + }; + await cache.set('user', obj); + const result = await cache.get('user'); + expect(result.name).toBe('Alice'); + expect(result.age).toBe(30); + expect(result.nested.active).toBe(true); + expect(result.nested.tags.length).toBe(3); + }); + + it('should return undefined for non-existent keys', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + const result = await cache.get('nonexistent'); + expect(result).toBe(undefined); + }); + + it('should delete a key', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('key1', 'value1'); + const deleted = await cache.del('key1'); + expect(deleted).toBe(true); + const result = await cache.get('key1'); + expect(result).toBe(undefined); + }); + + it('should return false when deleting non-existent key', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + const deleted = await cache.del('nonexistent'); + expect(deleted).toBe(false); + }); + + it('should check if key exists with has()', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('key1', 'value1'); + expect(await cache.has('key1')).toBe(true); + expect(await cache.has('nonexistent')).toBe(false); + }); + + it('should get all keys', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.set('key3', 'value3'); + const keys = await cache.keys(); + expect(keys.length).toBe(3); + expect(keys.includes('key1')).toBe(true); + expect(keys.includes('key2')).toBe(true); + expect(keys.includes('key3')).toBe(true); + }); + + it('should clear all values', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.clear(); + const keys = await cache.keys(); + expect(keys.length).toBe(0); + }); + + it('should create .lino files for each key', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('mykey', 'myvalue'); + const files = await fs.readdir(cacheDir); + const linoFiles = files.filter((f) => f.endsWith('.lino')); + expect(linoFiles.length).toBe(1); + }); +}); + +describe('LinoCache - Single File Mode', () => { + const cacheDir = path.join(TEST_CACHE_DIR, 'single'); + const cacheFile = 'test-cache.lino'; + + beforeEach(async () => { + await cleanup(cacheDir); + }); + + afterEach(async () => { + await cleanup(cacheDir); + }); + + it('should set and get a value', async () => { + const cache = new LinoCache({ + basePath: cacheDir, + mode: 'file', + fileName: cacheFile, + }); + await cache.set('key1', 'value1'); + const result = await cache.get('key1'); + expect(result).toBe('value1'); + }); + + it('should store all entries in a single file', async () => { + const cache = new LinoCache({ + basePath: cacheDir, + mode: 'file', + fileName: cacheFile, + }); + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.set('key3', 'value3'); + + const files = await fs.readdir(cacheDir); + const linoFiles = files.filter((f) => f.endsWith('.lino')); + expect(linoFiles.length).toBe(1); + expect(linoFiles[0]).toBe(cacheFile); + }); + + it('should delete a key from single file', async () => { + const cache = new LinoCache({ + basePath: cacheDir, + mode: 'file', + fileName: cacheFile, + }); + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.del('key1'); + expect(await cache.get('key1')).toBe(undefined); + expect(await cache.get('key2')).toBe('value2'); + }); + + it('should persist data across instances', async () => { + const cache1 = new LinoCache({ + basePath: cacheDir, + mode: 'file', + fileName: cacheFile, + }); + await cache1.set('persistent', 'data'); + await cache1.disconnect(); + + const cache2 = new LinoCache({ + basePath: cacheDir, + mode: 'file', + fileName: cacheFile, + }); + const result = await cache2.get('persistent'); + expect(result).toBe('data'); + }); +}); + +describe('LinoCache - TTL (Time To Live)', () => { + const cacheDir = path.join(TEST_CACHE_DIR, 'ttl'); + + beforeEach(async () => { + await cleanup(cacheDir); + }); + + afterEach(async () => { + await cleanup(cacheDir); + }); + + it('should expire entries after TTL', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('expiring', 'value', 100); // 100ms TTL + expect(await cache.get('expiring')).toBe('value'); + await delay(150); + expect(await cache.get('expiring')).toBe(undefined); + }); + + it('should use default TTL from options', async () => { + const cache = new LinoCache({ basePath: cacheDir, ttl: 100 }); + await cache.set('expiring', 'value'); + expect(await cache.get('expiring')).toBe('value'); + await delay(150); + expect(await cache.get('expiring')).toBe(undefined); + }); + + it('should override default TTL with set() parameter', async () => { + const cache = new LinoCache({ basePath: cacheDir, ttl: 1000 }); + await cache.set('expiring', 'value', 100); // Override with 100ms + await delay(150); + expect(await cache.get('expiring')).toBe(undefined); + }); + + it('should return remaining TTL', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('key', 'value', 5000); // 5 second TTL + const remaining = await cache.ttl('key'); + expect(remaining > 4000).toBe(true); + expect(remaining <= 5000).toBe(true); + }); + + it('should return -1 for keys with no TTL', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('key', 'value'); // No TTL + const remaining = await cache.ttl('key'); + expect(remaining).toBe(-1); + }); + + it('should return -2 for non-existent keys', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + const remaining = await cache.ttl('nonexistent'); + expect(remaining).toBe(-2); + }); +}); + +describe('LinoCache - Multi-key Operations', () => { + const cacheDir = path.join(TEST_CACHE_DIR, 'multi'); + + beforeEach(async () => { + await cleanup(cacheDir); }); - it('should add negative numbers', () => { - expect(add(-1, -2)).toBe(-3); + afterEach(async () => { + await cleanup(cacheDir); }); - it('should add zero', () => { - expect(add(5, 0)).toBe(5); + it('should set multiple values with mset', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.mset([ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: 'value2' }, + { key: 'key3', value: 'value3' }, + ]); + expect(await cache.get('key1')).toBe('value1'); + expect(await cache.get('key2')).toBe('value2'); + expect(await cache.get('key3')).toBe('value3'); + }); + + it('should get multiple values with mget', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + const results = await cache.mget(['key1', 'key2', 'nonexistent']); + expect(results[0]).toBe('value1'); + expect(results[1]).toBe('value2'); + expect(results[2]).toBe(undefined); + }); + + it('should delete multiple values with mdel', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.set('key3', 'value3'); + const deleted = await cache.mdel(['key1', 'key2', 'nonexistent']); + expect(deleted).toBe(true); + expect(await cache.get('key1')).toBe(undefined); + expect(await cache.get('key2')).toBe(undefined); + expect(await cache.get('key3')).toBe('value3'); }); }); -describe('multiply function', () => { - it('should multiply two positive numbers', () => { - expect(multiply(2, 3)).toBe(6); +describe('LinoCache - wrap()', () => { + const cacheDir = path.join(TEST_CACHE_DIR, 'wrap'); + + beforeEach(async () => { + await cleanup(cacheDir); + }); + + afterEach(async () => { + await cleanup(cacheDir); }); - it('should multiply by zero', () => { - expect(multiply(5, 0)).toBe(0); + it('should cache function result', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + let callCount = 0; + const getValue = async () => { + callCount++; + return 'expensive-result'; + }; + + const result1 = await cache.wrap('key', getValue); + const result2 = await cache.wrap('key', getValue); + + expect(result1).toBe('expensive-result'); + expect(result2).toBe('expensive-result'); + expect(callCount).toBe(1); // Function only called once + }); + + it('should work with sync functions', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + let callCount = 0; + const getValue = () => { + callCount++; + return 'sync-result'; + }; + + const result1 = await cache.wrap('key', getValue); + const result2 = await cache.wrap('key', getValue); + + expect(result1).toBe('sync-result'); + expect(result2).toBe('sync-result'); + expect(callCount).toBe(1); + }); + + it('should respect TTL in wrap', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + let callCount = 0; + const getValue = () => { + callCount++; + return `result-${callCount}`; + }; + + const result1 = await cache.wrap('key', getValue, 100); + expect(result1).toBe('result-1'); + + await delay(150); + + const result2 = await cache.wrap('key', getValue, 100); + expect(result2).toBe('result-2'); // Function called again after expiry + expect(callCount).toBe(2); }); +}); + +describe('LinoCache - Special Key Names', () => { + const cacheDir = path.join(TEST_CACHE_DIR, 'special'); - it('should multiply negative numbers', () => { - expect(multiply(-2, 3)).toBe(-6); + beforeEach(async () => { + await cleanup(cacheDir); }); + + afterEach(async () => { + await cleanup(cacheDir); + }); + + it('should handle keys with special characters', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('user:123', 'value1'); + await cache.set('item/abc', 'value2'); + await cache.set('key with spaces', 'value3'); + await cache.set('key.with.dots', 'value4'); + + expect(await cache.get('user:123')).toBe('value1'); + expect(await cache.get('item/abc')).toBe('value2'); + expect(await cache.get('key with spaces')).toBe('value3'); + expect(await cache.get('key.with.dots')).toBe('value4'); + }); + + it('should handle empty-like keys', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('...', 'dots'); + await cache.set(' ', 'spaces'); + + expect(await cache.get('...')).toBe('dots'); + expect(await cache.get(' ')).toBe('spaces'); + }); +}); + +describe('LinoCache - Factory Functions', () => { + const cacheDir = path.join(TEST_CACHE_DIR, 'factory'); + + beforeEach(async () => { + await cleanup(cacheDir); + }); + + afterEach(async () => { + await cleanup(cacheDir); + }); + + it('createLinoCache should create a LinoCache instance', async () => { + const cache = createLinoCache({ basePath: cacheDir }); + expect(cache instanceof LinoCache).toBe(true); + await cache.set('key', 'value'); + expect(await cache.get('key')).toBe('value'); + }); + + it('linoStore should create a cache-manager compatible store', async () => { + const store = linoStore({ basePath: cacheDir }); + expect(store instanceof LinoCache).toBe(true); + // Verify it has cache-manager interface methods + expect(typeof store.get).toBe('function'); + expect(typeof store.set).toBe('function'); + expect(typeof store.del).toBe('function'); + expect(typeof store.clear).toBe('function'); + expect(typeof store.reset).toBe('function'); + expect(typeof store.wrap).toBe('function'); + expect(typeof store.mget).toBe('function'); + expect(typeof store.mset).toBe('function'); + expect(typeof store.mdel).toBe('function'); + expect(typeof store.ttl).toBe('function'); + }); +}); + +describe('LinoCache - reset() alias', () => { + const cacheDir = path.join(TEST_CACHE_DIR, 'reset'); + + beforeEach(async () => { + await cleanup(cacheDir); + }); + + afterEach(async () => { + await cleanup(cacheDir); + }); + + it('reset should work as alias for clear', async () => { + const cache = new LinoCache({ basePath: cacheDir }); + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.reset(); + expect(await cache.get('key1')).toBe(undefined); + expect(await cache.get('key2')).toBe(undefined); + }); +}); + +// Cleanup test directory after all tests +afterEach(async () => { + try { + await fs.rm(TEST_CACHE_DIR, { recursive: true, force: true }); + } catch { + // Ignore + } });