10 KiB
SmartFS Architecture Hints
Overview
SmartFS is a modern, pluggable filesystem module built with TypeScript. It provides a fluent API for filesystem operations with support for multiple storage backends, transactions, streaming, and file watching.
Core Design Principles
1. Fluent API with Action-Last Pattern
The API uses a builder pattern where configuration methods return this for chaining, and action methods return Promise for execution:
await fs.file('/path')
.encoding('utf8') // configuration
.atomic() // configuration
.write('content'); // action (returns Promise)
Reasoning:
- Configuration is explicit and discoverable
- Action methods clearly indicate execution points
- Natural reading order: "configure what you want, then do it"
- Type-safe chaining prevents invalid operations
2. Provider Architecture
All filesystem operations go through a provider interface (ISmartFsProvider). This allows:
- Pluggable backends: Node.js fs, memory, S3, etc.
- Testing: Use memory provider for fast, isolated tests
- Abstraction: Hide platform-specific details
- Extensibility: Easy to add new storage backends
Provider Responsibilities:
- Implement all filesystem operations
- Handle path normalization
- Provide capability flags
- Implement transactions (or fall back to sequential)
3. Async-Only Design
No synchronous operations are exposed. All methods return Promises.
Reasoning:
- Modern async/await patterns
- Better performance (non-blocking)
- Consistent API surface
- Simplifies implementation
4. Web Streams API
Uses Web Streams (ReadableStream, WritableStream) instead of Node.js streams.
Reasoning:
- Standard API across platforms (Node.js, browser, Deno)
- Better composability with
.pipeTo() - Backpressure handling built-in
- Future-proof (web standard)
5. Transaction System
Transactions provide atomic multi-file operations with automatic rollback.
Implementation:
prepareTransaction()- Create backups of existing filesexecuteTransaction()- Execute all operationsrollbackTransaction()- Restore from backups if any operation fails
Trade-offs:
- Not truly atomic at OS level (no fsync barriers)
- Best-effort rollback (can fail if disk full, etc.)
- Sufficient for most use cases
6. File Watching
Event-based file watching with debouncing and filtering.
Features:
- Recursive watching
- Pattern filtering (glob, RegExp, function)
- Debouncing to reduce event spam
- Multiple event handlers per watcher
Implementation Notes:
- Node provider uses
fs.watch - Memory provider triggers events synchronously
- Events include stats when available
Directory Structure
ts/
├── classes/
│ ├── smartfs.ts # Main entry point
│ ├── smartfs.file.ts # File builder
│ ├── smartfs.directory.ts # Directory builder
│ ├── smartfs.transaction.ts # Transaction builder
│ └── smartfs.watcher.ts # Watcher builder
├── interfaces/
│ ├── mod.provider.ts # Provider interface
│ └── mod.types.ts # Type definitions
├── providers/
│ ├── smartfs.provider.node.ts # Node.js implementation
│ └── smartfs.provider.memory.ts # Memory implementation
└── index.ts # Public exports
Key Architectural Decisions
Why Action-Last?
Considered alternatives:
- Action-first:
fs.file('/path').read().asText()- Less intuitive - Mixed:
fs.read('/path', { encoding: 'utf8' })- Less fluent
Chosen: Action-last pattern
- Clear execution point
- Natural configuration flow
- Better IDE autocomplete
Why Separate Builders?
Each builder (SmartFsFile, SmartFsDirectory, etc.) is a separate class.
Benefits:
- Type safety (can't call
.list()on a file) - Clear separation of concerns
- Better code organization
- Easier to extend
Transaction Implementation
Design choice: Prepare → Execute → Rollback pattern
Alternative considered: Copy-on-write filesystem
- Would require provider-specific implementations
- More complex
- Not worth the complexity for most use cases
Chosen approach:
- Read existing files before operations
- Store backup data in operation objects
- Rollback by restoring from backups
Limitations:
- Not truly atomic (no cross-file locks)
- Rollback can fail (rare)
- Memory overhead for large files
Future improvements:
- Providers could override with native transactions
- Add transaction isolation levels
- Support for distributed transactions
Path Handling
All paths are normalized by the provider.
Reasoning:
- Providers know their path conventions
- Allows Windows vs. Unix path handling
- S3 provider can handle virtual paths
- Memory provider uses consistent format
Error Handling
Errors bubble up from providers.
Design:
- No custom error wrapping
- Provider errors are descriptive
- Use standard Node.js error codes (ENOENT, etc.)
Reasoning:
- Simpler implementation
- Familiar error messages
- Less abstraction overhead
Performance Considerations
Streaming
Use streams for files > 1MB to avoid loading entire file into memory.
Chunk size defaults:
- Read: 64KB (configurable via
.chunkSize()) - Write: 16KB (Node.js default)
Memory Provider
All data stored in Map<string, IMemoryEntry>.
Trade-offs:
- Fast (no I/O)
- Limited by available memory
- No persistence
- Perfect for testing
Node Provider
Direct use of Node.js fs/promises API.
Optimizations:
- Atomic writes use temp files + rename (atomic at OS level)
- Move operations try rename first, fallback to copy+delete
- Stat caching not implemented (can add if needed)
Testing Strategy
Test Organization
test/test.memory.provider.ts- Memory provider teststest/test.node.provider.ts- Node.js provider teststest/test.ts- Main test entry
Testing Approach
- Memory provider tests - Fast, no I/O, comprehensive
- Node provider tests - Real filesystem, integration
- Both test suites share similar test cases
Reasoning:
- Memory tests are fast and isolated
- Node tests verify real filesystem behavior
- Easy to add new provider tests
Future Enhancements
Potential Features
- S3 Provider - Cloud storage backend
- FTP Provider - Remote filesystem access
- Virtual Provider - Union of multiple providers
- Caching Layer - In-memory cache for frequently accessed files
- Compression - Transparent compression/decompression
- Encryption - Transparent encryption at rest
- Versioning - Automatic file versioning
API Extensions
- Shortcut methods -
fs.read('/path')as alternative tofs.file('/path').read() - Batch operations -
fs.batch().file(...).file(...).execute() - Query API - SQL-like queries for file listings
- Hooks - Before/after operation hooks
Dependencies
Core Dependencies
@push.rocks/smartpath- Path utilities@types/node- TypeScript types for Node.js
Why Minimal Dependencies?
Philosophy:
- Keep the core light
- Avoid dependency hell
- Easier to maintain
- Faster installation
Trade-off:
- More code to maintain
- Can't leverage external libraries
Decision:
- Worth it for a foundational library
- Providers can have their own dependencies
Compatibility
Node.js Versions
Requires Node.js 18+ for:
- Native Web Streams API
fs.rm()(replaces deprecatedfs.rmdir())fs/promisesAPI
Browser Compatibility
Core architecture supports browser, but:
- No browser provider implemented yet
- Would need IndexedDB or similar backend
- Stream handling already compatible
TypeScript
Uses ES2022 target, NodeNext modules.
Reasoning:
- Modern JavaScript features
- ESM-first approach
- Better tree-shaking
- Future-proof
Common Patterns
Provider Implementation Checklist
When implementing a new provider:
- ✅ Implement
ISmartFsProviderinterface - ✅ Set capability flags correctly
- ✅ Normalize paths in constructor/methods
- ✅ Handle errors with descriptive messages
- ✅ Implement transaction support (or fallback)
- ✅ Add comprehensive tests
- ✅ Document provider-specific limitations
Builder Pattern Example
class SmartFsFile {
private options = {};
// Configuration (returns this)
encoding(enc: string): this {
this.options.encoding = enc;
return this;
}
// Action (returns Promise)
async read(): Promise<string | Buffer> {
return this.provider.readFile(this.path, this.options);
}
}
Known Limitations
Transaction Atomicity
- Not truly atomic across files
- Rollback can fail in edge cases
- No distributed transaction support
Mitigation:
- Document limitations clearly
- Best-effort rollback is sufficient for most cases
- Providers can override with native transactions
File Watching
- Node.js
fs.watchhas platform-specific behavior - May miss rapid changes
- No guarantee of event order
Mitigation:
- Debouncing helps with rapid changes
- Document platform differences
- Memory provider has predictable behavior (testing)
Path Handling
- No cross-provider path compatibility
- Provider-specific path formats
Mitigation:
- Document path format per provider
- Use provider's
normalizePath()method - Consider adding path conversion utilities
Maintenance Notes
When to Update
- Breaking changes: Avoid unless absolutely necessary
- New features: Add as fluent methods or new builders
- Bug fixes: Prioritize data integrity
- Performance: Profile before optimizing
Code Style
- Use TypeScript strict mode
- Prefer composition over inheritance
- Keep classes focused (SRP)
- Document public APIs with JSDoc
- Use meaningful variable names
Testing Requirements
All changes must:
- Pass existing tests
- Add new tests for new features
- Maintain >90% code coverage
- Test both memory and Node providers