{"id":50493484,"url":"https://github.com/bradfaas/mongoose-chronicle","last_synced_at":"2026-06-02T05:00:50.229Z","repository":{"id":328237909,"uuid":"1114718979","full_name":"bradfaas/mongoose-chronicle","owner":"bradfaas","description":"A Mongoose plugin that provides granular document history, branching, and snapshot capabilities for MongoDB documents.","archived":false,"fork":false,"pushed_at":"2026-06-01T20:49:33.000Z","size":3847,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-01T21:25:14.548Z","etag":null,"topics":["branching","mongoose","snapshot","time-machine"],"latest_commit_sha":null,"homepage":"https://github.com/bradfaas/mongoose-chronicle","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/bradfaas.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-12-11T19:33:20.000Z","updated_at":"2026-06-01T20:49:37.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/bradfaas/mongoose-chronicle","commit_stats":null,"previous_names":["bradfaas/mongoose-chronicle"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/bradfaas/mongoose-chronicle","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bradfaas%2Fmongoose-chronicle","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bradfaas%2Fmongoose-chronicle/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bradfaas%2Fmongoose-chronicle/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bradfaas%2Fmongoose-chronicle/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/bradfaas","download_url":"https://codeload.github.com/bradfaas/mongoose-chronicle/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/bradfaas%2Fmongoose-chronicle/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33806987,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-02T02:00:07.132Z","response_time":109,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["branching","mongoose","snapshot","time-machine"],"created_at":"2026-06-02T05:00:46.781Z","updated_at":"2026-06-02T05:00:50.219Z","avatar_url":"https://github.com/bradfaas.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# mongoose-chronicle\n\nA Mongoose plugin that provides granular document history, branching, and snapshot capabilities for MongoDB documents.\n\n## Features\n\n- **Incremental Change History** - Every document change is preserved forever as a series of \"chunks\"\n- **Delta Storage** - Only changed fields are stored for updates, reducing storage overhead\n- **Point-in-Time Queries** - Retrieve any document's state at any moment in history\n- **Branching** - Create branches from any point in a document's history (similar to git)\n- **Snapshots** - Save named points-in-time for easy reference\n- **Transparent Operation** - Existing Mongoose code works without modification\n- **Soft Deletes** - Deleted documents can be recovered or viewed historically\n- **Automatic Index Analysis** - Detects indexes and unique constraints from your schema\n- **Unique Constraint Enforcement** - Validates unique fields across chronicle history\n\n## Installation\n\n```bash\nnpm install mongoose-chronicle\n```\n\n**Note:** Mongoose 7.0.0 or higher is required as a peer dependency.\n\n## Quick Start\n\n```typescript\nimport mongoose from 'mongoose';\nimport { chroniclePlugin, initializeChronicle } from 'mongoose-chronicle';\n\n// Define your schema as usual\nconst ProductSchema = new mongoose.Schema({\n  sku: { type: String, required: true, unique: true },\n  name: { type: String, required: true, index: true },\n  price: { type: Number, default: 0 },\n});\n\n// Apply the chronicle plugin\nProductSchema.plugin(chroniclePlugin, {\n  fullChunkInterval: 10, // Create a full snapshot every 10 changes\n});\n\n// Create your model\nconst Product = mongoose.model('Product', ProductSchema);\n\n// Initialize chronicle collections (call once at startup)\nawait initializeChronicle(mongoose.connection, 'products');\n```\n\n## How It Works\n\n### Architecture Overview\n\nWhen you apply the chronicle plugin to a schema, mongoose-chronicle:\n\n1. **Intercepts save operations** via Mongoose middleware\n2. **Creates ChronicleChunks** in a separate `{collection}_chronicle_chunks` collection\n3. **Maintains metadata** to track document state and active branches\n4. **Enforces unique constraints** via a dedicated `{collection}_chronicle_keys` collection\n5. **Continues normal Mongoose save** for backward compatibility with existing queries\n\n### Collection Structure\n\nFor a model with collection name `products`, the plugin creates:\n\n| Collection | Purpose |\n|------------|---------|\n| `products` | Original documents (standard Mongoose behavior) |\n| `products_chronicle_chunks` | Historical chunks (full and delta) |\n| `products_chronicle_metadata` | Tracks active branch per document |\n| `products_chronicle_branches` | Branch information |\n| `products_chronicle_keys` | Current unique key values for constraint enforcement |\n| `chronicle_config` | Plugin configuration per collection |\n\n### ChronicleChunk Storage\n\nDocuments are stored as \"ChronicleChunks\" with full/delta compression:\n\n**Initial creation - stored as a \"full\" chunk (ccType: 1):**\n```javascript\n{\n  \"_id\": \"507f1f77bcf86cd799439012\",\n  \"docId\": \"507f1f77bcf86cd799439011\",\n  \"branchId\": \"507f1f77bcf86cd799439013\",\n  \"serial\": 1,\n  \"ccType\": 1,  // 1 = full\n  \"isDeleted\": false,\n  \"isLatest\": true,\n  \"cTime\": \"2025-01-15T10:00:00.000Z\",\n  \"payload\": {\n    \"sku\": \"WIDGET-001\",\n    \"name\": \"Blue Widget\",\n    \"price\": 29.99\n  }\n}\n```\n\n**Subsequent update - stored as a \"delta\" chunk (ccType: 2):**\n```javascript\n{\n  \"_id\": \"507f1f77bcf86cd799439014\",\n  \"docId\": \"507f1f77bcf86cd799439011\",\n  \"branchId\": \"507f1f77bcf86cd799439013\",\n  \"serial\": 2,\n  \"ccType\": 2,  // 2 = delta\n  \"isDeleted\": false,\n  \"isLatest\": true,  // Previous chunk's isLatest is set to false\n  \"cTime\": \"2025-01-15T11:00:00.000Z\",\n  \"payload\": {\n    \"price\": 24.99  // Only the changed field\n  }\n}\n```\n\n### Document Rehydration\n\nWhen you query for a document's history, mongoose-chronicle:\n\n1. Finds the most recent \"full\" chunk for the document\n2. Applies all subsequent \"delta\" chunks in order\n3. Returns the fully rehydrated document state\n\n### Automatic Index Detection\n\nThe plugin automatically analyzes your schema to detect:\n\n- **Indexed fields** - Creates optimized payload indexes in chronicle collections\n- **Unique fields** - Enforces uniqueness via the chronicle_keys collection\n- **Compound indexes** - Preserves compound index information\n\n```typescript\nconst ProductSchema = new mongoose.Schema({\n  sku: { type: String, unique: true },      // Detected as unique\n  name: { type: String, index: true },       // Detected as indexed\n  category: { type: String },\n});\n\n// The plugin automatically detects sku as unique and name as indexed\nProductSchema.plugin(chroniclePlugin);\n```\n\n### Unique Constraint Handling\n\nSince chronicle stores multiple versions of documents, traditional MongoDB unique indexes won't work correctly. The plugin uses a dedicated `chronicle_keys` collection to:\n\n1. **Track current values** of unique fields per document/branch\n2. **Validate before save** that no conflicts exist\n3. **Support sparse uniqueness** (null/undefined values are allowed for multiple documents)\n\n```typescript\n// Unique constraint is enforced through chronicle_keys collection\nconst doc1 = new Product({ sku: 'SKU001', name: 'Widget' });\nawait doc1.save(); // Success\n\nconst doc2 = new Product({ sku: 'SKU001', name: 'Gadget' });\nawait doc2.save(); // Throws error: Duplicate key error: sku \"SKU001\" already exists\n```\n\n## Configuration Options\n\n```typescript\ninterface ChroniclePluginOptions {\n  // Property to use as document identifier (default: '_id')\n  primaryKey?: string;\n\n  // Number of deltas before creating a new full chunk (default: 10)\n  fullChunkInterval?: number;\n\n  // Fields to index in the payload (auto-detected from schema if not provided)\n  indexes?: string[];\n\n  // Fields with unique constraints (auto-detected from schema if not provided)\n  uniqueKeys?: string[];\n\n  // Name of the config collection (default: 'chronicle_config')\n  configCollectionName?: string;\n\n  // Name of the metadata collection (default: '{collection}_chronicle_metadata')\n  metadataCollectionName?: string;\n\n  // Maximum documents deleteMany can affect before throwing error (default: 100)\n  // Use { chronicleForceDeleteMany: true } in query options to bypass\n  deleteManyLimit?: number;\n}\n```\n\n### Example with All Options\n\n```typescript\nProductSchema.plugin(chroniclePlugin, {\n  primaryKey: 'sku',           // Use 'sku' instead of '_id' as document identifier\n  fullChunkInterval: 20,       // Create full snapshot every 20 changes\n  indexes: ['name', 'price'],  // Override auto-detected indexes\n  uniqueKeys: ['sku'],         // Override auto-detected unique keys\n  configCollectionName: 'my_chronicle_config',\n  metadataCollectionName: 'products_metadata',\n});\n```\n\n## API Reference\n\n### Plugin Functions\n\n#### chroniclePlugin\n\nThe main plugin function to apply to your schema:\n\n```typescript\nimport { chroniclePlugin } from 'mongoose-chronicle';\n\nschema.plugin(chroniclePlugin, options);\n```\n\n#### initializeChronicle\n\nInitialize chronicle collections and configuration. Call once at startup:\n\n```typescript\nimport { initializeChronicle } from 'mongoose-chronicle';\n\nawait initializeChronicle(\n  mongoose.connection,  // Mongoose connection\n  'products',           // Collection name\n  options               // Optional: same options as plugin\n);\n```\n\n### Instance Methods\n\nOnce the plugin is applied, your documents gain additional methods:\n\n```typescript\n// Get complete history of a document\nconst history = await product.getHistory();\n\n// Create a named snapshot (branch) at current state\nconst snapshot = await product.createSnapshot('v1.0-release');\n\n// List all branches for this document\nconst branches = await product.getBranches();\n```\n\n### Static Methods\n\n```typescript\n// Find document state at a specific point in time\nconst pastState = await Product.findAsOf(\n  { sku: 'WIDGET-001' },\n  new Date('2025-01-01')\n);\n\n// Create a new branch from a document (auto-activates by default)\nconst branch = await Product.createBranch(docId, 'experimental-pricing');\n\n// Create a branch without activating it\nconst archiveBranch = await Product.createBranch(docId, 'archived-snapshot', { activate: false });\n\n// Create a branch from a specific serial (point in history)\nconst branch = await Product.createBranch(docId, 'hotfix', { fromSerial: 5 });\n\n// Switch active branch for a document\nawait Product.switchBranch(docId, branchId);\n\n// List all branches for a document\nconst branches = await Product.listBranches(docId);\n\n// Get the currently active branch for a document\nconst activeBranch = await Product.getActiveBranch(docId);\n\n// Revert a branch to a specific serial (undo changes)\nconst revertResult = await Product.chronicleRevert(docId, 5);\n// Returns: { success: true, revertedToSerial: 5, chunksRemoved: 3, state: {...} }\n\n// Revert on a specific branch without rehydrating\nawait Product.chronicleRevert(docId, 3, { branchId: someBranchId, rehydrate: false });\n\n// Get document state at a specific point in time\nconst historicalState = await Product.chronicleAsOf(docId, new Date('2024-06-15'));\n// Returns: { found: true, state: {...}, serial: 5, branchId: '...', chunkTimestamp: Date }\n\n// Query a specific branch at a point in time\nconst branchState = await Product.chronicleAsOf(docId, targetDate, { branchId: featureBranchId });\n\n// Search across all branches for state at a timestamp\nconst crossBranchState = await Product.chronicleAsOf(docId, auditDate, { searchAllBranches: true });\n\n// Preview what squash would delete (dry run)\nconst preview = await Product.chronicleSquash(docId, 5, { dryRun: true, confirm: false });\n// Returns: { wouldDelete: { chunks: 47, branches: 5 }, newBaseState: {...} }\n\n// Squash all history to a single point (destructive, irreversible)\nconst squashResult = await Product.chronicleSquash(docId, 5, { confirm: true });\n// Returns: { success: true, previousChunkCount: 47, previousBranchCount: 5, newState: {...} }\n\n// Soft delete a document (preserves history)\nconst deleteResult = await Product.chronicleSoftDelete(docId);\n// Returns: { chunkId: '...', finalState: { /* state at deletion */ } }\n\n// Restore a soft-deleted document\nconst restoreResult = await Product.chronicleUndelete(docId);\n// Returns: { success: true, docId: '...', epoch: 1, restoredState: {...} }\n\n// List all soft-deleted documents\nconst deletedDocs = await Product.chronicleListDeleted();\n// Returns: [{ docId, epoch, deletedAt, finalState }]\n\n// List deleted documents within a time range\nconst recentDeleted = await Product.chronicleListDeleted({\n  deletedAfter: new Date('2024-01-01'),\n  deletedBefore: new Date('2024-12-31'),\n});\n\n// Permanently purge all chronicle data (destructive, irreversible)\nconst purgeResult = await Product.chroniclePurge(docId, { confirm: true });\n// Returns: { success: true, docId, epochsPurged: [1], chunksRemoved: 47, branchesRemoved: 5 }\n```\n\n### Utility Functions\n\n```typescript\nimport {\n  computeDelta,\n  applyDelta,\n  applyDeltas,\n  isDeltaEmpty,\n  analyzeSchemaIndexes,\n  createCleanPayloadSchema,\n  generateChronicleIndexes,\n} from 'mongoose-chronicle';\n\n// Compute difference between two objects\nconst delta = computeDelta(oldDoc, newDoc);\n\n// Apply a delta to a base object\nconst result = applyDelta(baseDoc, delta);\n\n// Apply multiple deltas sequentially\nconst finalState = applyDeltas(baseDoc, [delta1, delta2, delta3]);\n\n// Check if a delta has any changes\nif (!isDeltaEmpty(delta)) {\n  // There are changes to save\n}\n\n// Analyze a schema for index information\nconst analysis = analyzeSchemaIndexes(schema);\nconsole.log(analysis.indexedFields);  // Fields with index: true\nconsole.log(analysis.uniqueFields);   // Fields with unique: true\nconsole.log(analysis.compoundIndexes); // Compound indexes defined on schema\n```\n\n## Branching\n\nBranching allows you to create alternate timelines for a document, similar to Git branches.\n\n### Creating Branches\n\nBy default, `createBranch` automatically activates the new branch (like `git checkout -b`):\n\n```typescript\n// Create and switch to a new branch in one call\nconst featureBranch = await Product.createBranch(productId, 'feature-x');\n\n// Subsequent saves automatically go to the new branch\nproduct.price = 19.99;\nawait product.save(); // This change is recorded on 'feature-x', not 'main'\n```\n\nIf you need to create a branch without switching to it (e.g., for bookmarks, archived snapshots, or preview branches), use `{ activate: false }`:\n\n```typescript\n// Create a bookmark branch\nconst hotfix = await Product.createBranch(productId, 'june-bookmark', {\n  activate: false   // Subsequent changes are saved to the original branch, not the newly created 'june-bookmark' branch.\n});\n```\n\n### Branch Options\n\n```typescript\ninterface CreateBranchOptions {\n  // Whether to activate the branch after creation (default: true)\n  activate?: boolean;\n\n  // Serial number to branch from (default: latest serial on active branch)\n  fromSerial?: number;\n}\n```\n\n### Common Use Cases\n\n```typescript\n// Create a branch for testing (auto-activates)\nconst testBranch = await Product.createBranch(productId, 'price-test');\n\n// Create an archived snapshot without switching to it\nconst snapshot = await Product.createBranch(productId, 'v1.0-release', {\n  activate: false\n});\n\n// Create a hotfix branch from a specific point in history\nconst hotfix = await Product.createBranch(productId, 'hotfix-123', {\n  fromSerial: 5,   // Branch from serial 5\n  activate: true   // And switch to it\n});\n\n// Check which branch is currently active\nconst active = await Product.getActiveBranch(productId);\nconsole.log(active.name); // 'hotfix-123'\n\n// List all branches for the document\nconst branches = await Product.listBranches(productId);\n// Returns: [{ name: 'main', ... }, { name: 'price-test', ... }, ...]\n\n// Switch back to main branch\nconst mainBranch = branches.find(b =\u003e b.name === 'main');\nawait Product.switchBranch(productId, mainBranch._id);\n```\n\n## History Management\n\nmongoose-chronicle provides two operations for managing chronicle history: **Revert** (undo changes on a branch) and **Squash** (collapse all history to a single point).\n\n### Revert (`chronicleRevert`)\n\nRevert a branch's history to a specific serial, removing all chunks newer than the target. This only affects the specified branch - other branches remain untouched.\n\n```typescript\n// Revert active branch to serial 5\nconst result = await Product.chronicleRevert(productId, 5);\n// result: {\n//   success: true,\n//   revertedToSerial: 5,\n//   chunksRemoved: 3,\n//   branchesUpdated: 1,  // Child branches whose parentSerial was updated\n//   state: { /* rehydrated document state */ }\n// }\n\n// Revert a specific branch without rehydrating\nawait Product.chronicleRevert(productId, 3, {\n  branchId: featureBranchId,\n  rehydrate: false\n});\n```\n\n**RevertOptions:**\n| Parameter | Type | Default | Description |\n|-----------|------|---------|-------------|\n| `branchId` | ObjectId | active branch | Target branch to revert |\n| `rehydrate` | boolean | `true` | Return the document state after revert |\n\n**Behavior:**\n- Validates target serial exists on the branch\n- Deletes all chunks with `serial \u003e targetSerial`\n- Marks target chunk as `isLatest: true`\n- Updates orphaned child branches (sets their `parentSerial` to target if it was higher)\n- Returns rehydrated state if `rehydrate: true`\n\n### Squash (`chronicleSquash`)\n\nCollapse ALL chronicle history into a single FULL chunk. This is a **destructive, irreversible** operation that removes all branches and history, creating a clean baseline.\n\n```typescript\n// Preview what would be deleted (dry run)\nconst preview = await Product.chronicleSquash(productId, 5, {\n  dryRun: true,\n  confirm: false\n});\n// preview: {\n//   wouldDelete: { chunks: 47, branches: 5 },\n//   newBaseState: { /* document state at serial 5 */ }\n// }\n\n// Execute squash (requires explicit confirmation)\nconst result = await Product.chronicleSquash(productId, 5, { confirm: true });\n// result: {\n//   success: true,\n//   previousChunkCount: 47,\n//   previousBranchCount: 5,\n//   newState: { /* the new baseline state */ }\n// }\n\n// Squash to a state from a specific branch\nawait Product.chronicleSquash(productId, 3, {\n  branchId: featureBranchId,\n  confirm: true\n});\n```\n\n**SquashOptions:**\n| Parameter | Type | Required | Description |\n|-----------|------|----------|-------------|\n| `confirm` | boolean | Yes | Must be `true` to execute (safety measure) |\n| `branchId` | ObjectId | No | Which branch the target serial is on |\n| `dryRun` | boolean | No | Preview without executing |\n\n**Behavior:**\n1. Rehydrates document state at the specified serial\n2. Deletes ALL chunks across ALL branches\n3. Deletes ALL branches\n4. Creates new `main` branch with a single FULL chunk (serial: 1)\n5. Updates metadata to point to the new main branch\n\n### Comparison: Revert vs Squash\n\n| Aspect | `chronicleRevert` | `chronicleSquash` |\n|--------|-------------------|-------------------|\n| **Scope** | Single branch | All branches |\n| **Removes** | Newer chunks only | All chunks except new base |\n| **Preserves** | Older history + other branches | Nothing |\n| **Creates new chunk** | No | Yes (FULL at serial 1) |\n| **Confirmation required** | No | Yes (`confirm: true`) |\n| **Reversible** | Partially (deleted chunks are gone) | No |\n| **Use case** | Undo recent changes | Clean slate / storage cleanup |\n\n## Point-in-Time Queries (`chronicleAsOf`)\n\nQuery the state of a document at any arbitrary point in time. This is essential for auditing, debugging, compliance, and temporal data analysis.\n\n### Basic Usage\n\n```typescript\n// Get document state as of yesterday\nconst yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);\nconst result = await Product.chronicleAsOf(productId, yesterday);\n\nif (result.found) {\n  console.log('State at', result.chunkTimestamp, ':', result.state);\n  console.log('Was at serial', result.serial);\n} else {\n  console.log('No data exists for this document at that time');\n}\n```\n\n### Query Options\n\n```typescript\ninterface AsOfOptions {\n  // Specific branch to query (default: active branch)\n  branchId?: ObjectId;\n\n  // Search across all branches for state at timestamp\n  // Returns state from branch with most recent chunk at or before asOf\n  // Mutually exclusive with branchId\n  searchAllBranches?: boolean;\n}\n```\n\n### Result Interface\n\n```typescript\ninterface AsOfResult {\n  // Whether a valid state was found\n  found: boolean;\n\n  // The rehydrated document state (undefined if found is false)\n  state?: Record\u003cstring, unknown\u003e;\n\n  // Serial number of the chunk that was current at the timestamp\n  serial?: number;\n\n  // Branch ID from which the state was retrieved\n  branchId?: ObjectId;\n\n  // Exact timestamp of the chunk used (may be earlier than requested asOf)\n  chunkTimestamp?: Date;\n}\n```\n\n### Use Cases\n\n**Audit \u0026 Compliance** - Retrieve exact state at audit points:\n```typescript\nconst auditDate = new Date('2024-12-31T23:59:59Z');\nconst stateAtYearEnd = await Invoice.chronicleAsOf(invoiceId, auditDate);\n```\n\n**Debugging / Incident Response** - Investigate document state when issues occurred:\n```typescript\nconst incidentTime = new Date('2024-03-15T14:32:00Z');\nconst stateAtIncident = await Order.chronicleAsOf(orderId, incidentTime);\n```\n\n**Historical Reporting** - Generate reports based on historical data:\n```typescript\nconst reportDate = new Date('2024-06-30');\nconst products = await getProductIds();\nconst historicalStates = await Promise.all(\n  products.map(id =\u003e Product.chronicleAsOf(id, reportDate))\n);\n```\n\n**Diff Between Two Points in Time** - Compare document state:\n```typescript\nconst before = await Product.chronicleAsOf(id, startDate);\nconst after = await Product.chronicleAsOf(id, endDate);\n// Use your preferred diff library to compare before.state and after.state\n```\n\n### Specific Branch Query\n\n```typescript\n// Get state from a specific branch at a specific time\nconst result = await Product.chronicleAsOf(productId, targetDate, {\n  branchId: featureBranchId\n});\n```\n\n### Cross-Branch Search\n\nWhen you need to find state across any branch at a given time:\n\n```typescript\n// Search all branches and return state from whichever had the most recent chunk\nconst result = await Product.chronicleAsOf(productId, auditDate, {\n  searchAllBranches: true\n});\n\nif (result.found) {\n  console.log('Found on branch:', result.branchId);\n}\n```\n\n### Edge Cases\n\n| Scenario | Behavior |\n|----------|----------|\n| No chunks exist before `asOf` | Returns `{ found: false }` |\n| Document didn't exist at `asOf` | Returns `{ found: false }` |\n| Branch didn't exist at `asOf` | Returns `{ found: false }` |\n| `asOf` is in the future | Returns current/latest state |\n| `asOf` exactly matches a chunk timestamp | Includes that chunk in rehydration |\n| `branchId` and `searchAllBranches` both provided | Throws error (mutually exclusive) |\n\n## Transparent Soft Delete\n\nmongoose-chronicle provides transparent soft delete functionality that intercepts standard Mongoose delete operations and converts them into chronicle deletion chunks. Documents are marked as deleted but remain in the database with full history preserved.\n\n### How It Works\n\nWhen you call standard Mongoose delete methods (`findByIdAndDelete`, `findOneAndDelete`, `deleteOne`, `deleteMany`), the plugin:\n\n1. Intercepts the delete operation via middleware\n2. Creates a chronicle deletion chunk with `isDeleted: true`\n3. Sets `__chronicle_deleted: true` on the document\n4. Prevents actual document removal from the collection\n\nDeleted documents are automatically excluded from regular queries but can be accessed using escape hatches.\n\n### Delete Operations\n\nAll standard Mongoose delete operations are transparently converted to soft deletes:\n\n```typescript\n// All of these create chronicle deletion chunks instead of removing documents\nawait Product.findByIdAndDelete(productId);\nawait Product.findOneAndDelete({ sku: 'WIDGET-001' });\nawait Product.deleteOne({ _id: productId });\nawait Product.deleteMany({ category: 'discontinued' });\n```\n\n### deleteMany Safety Limit\n\nTo prevent accidental mass deletions, `deleteMany` throws an error if it would affect more than 100 documents (configurable):\n\n```typescript\n// Throws error if \u003e 100 documents would be affected\nawait Product.deleteMany({ category: 'old' });\n// Error: deleteMany would affect 150 documents, exceeding limit of 100. Use { chronicleForceDeleteMany: true } to bypass.\n\n// Bypass the safety limit when intentional\nawait Product.deleteMany({ category: 'old' }, { chronicleForceDeleteMany: true });\n\n// Configure a different limit via plugin options\nProductSchema.plugin(chroniclePlugin, {\n  deleteManyLimit: 50  // Lower limit for this collection\n});\n```\n\n### Query Filtering\n\nDeleted documents are automatically excluded from `find()` and `findOne()` queries:\n\n```typescript\n// Create and delete a document\nconst product = new Product({ name: 'Widget' });\nawait product.save();\nawait Product.findByIdAndDelete(product._id);\n\n// Regular queries exclude deleted documents\nawait Product.find({});                    // Returns []\nawait Product.findOne({ _id: product._id }); // Returns null\nawait Product.countDocuments({});          // Returns 0\n```\n\n### Escape Hatches\n\nTo include deleted documents in queries, use either:\n\n**Option 1: Query helper chain method**\n```typescript\n// Include deleted documents using chain method\nconst allDocs = await Product.find({}).includeDeleted();\nconst doc = await Product.findOne({ _id: productId }).includeDeleted();\n```\n\n**Option 2: Query options**\n```typescript\n// Include deleted documents using query options\nconst allDocs = await Product.find({}, null, { includeDeleted: true });\nconst doc = await Product.findOne({ _id: productId }, null, { includeDeleted: true });\n```\n\n### Branch Recovery\n\nWhen you create a branch from a deleted document's history, the document is automatically restored in the main collection:\n\n```typescript\n// Create a document\nconst product = new Product({ name: 'Widget', price: 100 });\nawait product.save();\nproduct.price = 150;\nawait product.save();\n\n// Delete the document\nawait Product.findByIdAndDelete(product._id);\n\n// Document is now soft-deleted\nawait Product.findById(product._id); // Returns null\n\n// Create a branch from serial 1 (the original state)\nawait Product.createBranch(product._id, 'recovery-branch', { fromSerial: 1 });\n\n// Document is automatically restored with the state at serial 1\nconst restored = await Product.findById(product._id);\nconsole.log(restored.price); // 100 (from serial 1)\n```\n\n### Branch Switch State Sync\n\nWhen you switch branches, the main collection document is synchronized to reflect the new branch's state:\n\n```typescript\n// Create document and a feature branch\nconst product = new Product({ name: 'Widget', value: 1 });\nawait product.save();\n\nconst featureBranch = await Product.createBranch(product._id, 'feature');\nproduct.value = 100;\nawait product.save(); // Saved on feature branch\n\n// Get main branch ID\nconst branches = await Product.listBranches(product._id);\nconst mainBranch = branches.find(b =\u003e b.name === 'main');\n\n// Switch back to main - document syncs to main branch state\nawait Product.switchBranch(product._id, mainBranch._id);\nconst mainState = await Product.findById(product._id);\nconsole.log(mainState.value); // 1 (main branch state)\n\n// Switch to feature - document syncs to feature branch state\nawait Product.switchBranch(product._id, featureBranch._id);\nconst featureState = await Product.findById(product._id);\nconsole.log(featureState.value); // 100 (feature branch state)\n```\n\nIf you switch to a branch where the document is deleted, the main collection document is marked as `__chronicle_deleted: true`.\n\n### Using chronicleUndelete\n\nThe `chronicleUndelete` method also syncs the main collection:\n\n```typescript\n// Soft delete a document\nawait Product.findByIdAndDelete(product._id);\n\n// Document is not findable\nawait Product.findById(product._id); // Returns null\n\n// Restore the document\nconst result = await Product.chronicleUndelete(product._id);\n\n// Document is back and queryable\nconst restored = await Product.findById(product._id);\nconsole.log(restored.name); // 'Widget'\n```\n\n### The `__chronicle_deleted` Field\n\nThe plugin adds a `__chronicle_deleted` boolean field to your documents:\n\n- `false` (default): Document is active and included in queries\n- `true`: Document is soft-deleted and excluded from queries\n\nThis field is indexed for efficient query filtering. You generally don't need to interact with this field directly - the middleware and query helpers handle it automatically.\n\n### Configuration\n\n```typescript\ninterface ChroniclePluginOptions {\n  // ... other options ...\n\n  // Maximum documents deleteMany can affect before throwing error\n  // Use { chronicleForceDeleteMany: true } to bypass\n  // Default: 100\n  deleteManyLimit?: number;\n}\n```\n\n## Schema Types\n\n### ChronicleChunk\n\n```typescript\ninterface ChronicleChunk\u003cT\u003e {\n  _id: ObjectId;           // Unique chunk ID\n  docId: ObjectId;         // Original document ID\n  branchId: ObjectId;      // Branch this chunk belongs to\n  serial: number;          // Sequential number within branch\n  ccType: 1 | 2;           // 1 = full, 2 = delta\n  isDeleted: boolean;      // Soft delete flag\n  isLatest: boolean;       // True for the most recent chunk per doc/branch\n  cTime: Date;             // Creation timestamp\n  payload: Partial\u003cT\u003e;     // Document data or delta\n}\n```\n\n### ChronicleMetadata\n\n```typescript\ninterface ChronicleMetadata {\n  _id: ObjectId;\n  docId: ObjectId;                              // Document this metadata belongs to\n  activeBranchId: ObjectId;                     // Currently active branch\n  metadataStatus: 'pending' | 'active' | 'orphaned';\n  createdAt: Date;\n  updatedAt: Date;\n}\n```\n\n### ChronicleBranch\n\n```typescript\ninterface ChronicleBranch {\n  _id: ObjectId;\n  docId: ObjectId;                    // Document this branch belongs to\n  parentBranchId: ObjectId | null;    // Parent branch (null for main)\n  parentSerial: number | null;        // Serial in parent where branch was created\n  name: string;                       // Human-readable branch name\n  createdAt: Date;\n}\n```\n\n### ChronicleKeys\n\n```typescript\ninterface ChronicleKeys {\n  _id: ObjectId;\n  docId: ObjectId;           // Reference to the document\n  branchId: ObjectId;        // Branch this key entry belongs to\n  isDeleted: boolean;        // Whether the document is deleted\n  key_fieldName: unknown;    // Dynamic fields for each unique key\n  createdAt: Date;\n  updatedAt: Date;\n}\n```\n\n### ChronicleConfig\n\n```typescript\ninterface ChronicleConfig {\n  _id: ObjectId;\n  collectionName: string;       // Collection this config applies to\n  fullChunkInterval: number;    // Full chunk interval setting\n  pluginVersion: string;        // Plugin version for migrations\n  indexedFields: string[];      // Fields that are indexed\n  uniqueFields: string[];       // Fields with unique constraints\n  createdAt: Date;\n  updatedAt: Date;\n}\n```\n\n### CreateBranchOptions\n\n```typescript\ninterface CreateBranchOptions {\n  // Whether to activate the branch after creation (default: true)\n  // When true, subsequent saves will be recorded on the new branch\n  activate?: boolean;\n\n  // Serial number to branch from (default: latest serial on active branch)\n  // Allows creating branches from any point in history\n  fromSerial?: number;\n}\n```\n\n### RevertOptions\n\n```typescript\ninterface RevertOptions {\n  // Target branch to revert (default: active branch)\n  branchId?: ObjectId;\n\n  // If true, return the rehydrated document state (default: true)\n  rehydrate?: boolean;\n}\n```\n\n### RevertResult\n\n```typescript\ninterface RevertResult {\n  success: boolean;\n  revertedToSerial: number;\n  chunksRemoved: number;\n  branchesUpdated: number;  // Branches whose parentSerial was updated\n  state?: Record\u003cstring, unknown\u003e;  // Rehydrated state if rehydrate: true\n}\n```\n\n### SquashOptions\n\n```typescript\ninterface SquashOptions {\n  // Which branch the target serial is on (default: active branch)\n  branchId?: ObjectId;\n\n  // Safety flag - must be true to execute (required)\n  confirm: boolean;\n\n  // If true, preview without executing\n  dryRun?: boolean;\n}\n```\n\n### SquashResult\n\n```typescript\ninterface SquashResult {\n  success: boolean;\n  previousChunkCount: number;\n  previousBranchCount: number;\n  newState: Record\u003cstring, unknown\u003e;\n}\n\ninterface SquashDryRunResult {\n  wouldDelete: {\n    chunks: number;\n    branches: number;\n  };\n  newBaseState: Record\u003cstring, unknown\u003e;\n}\n```\n\n### AsOfOptions\n\n```typescript\ninterface AsOfOptions {\n  // Specific branch to query (default: active branch)\n  branchId?: ObjectId;\n\n  // Search all branches and return state from the one with most recent chunk\n  // Mutually exclusive with branchId\n  searchAllBranches?: boolean;\n}\n```\n\n### AsOfResult\n\n```typescript\ninterface AsOfResult {\n  // Whether a valid state was found at the timestamp\n  found: boolean;\n\n  // The rehydrated document state (undefined if found is false)\n  state?: Record\u003cstring, unknown\u003e;\n\n  // Serial number of the chunk current at the timestamp\n  serial?: number;\n\n  // Branch ID from which the state was retrieved\n  branchId?: ObjectId;\n\n  // Exact timestamp of the chunk used (may be earlier than asOf)\n  chunkTimestamp?: Date;\n}\n```\n\n## Indexes\n\nThe plugin creates optimized indexes on chronicle collections:\n\n### Core Chronicle Indexes\n\n| Index Name | Fields | Purpose |\n|------------|--------|---------|\n| `chronicle_lookup` | `{ docId: 1, branchId: 1, serial: -1 }` | Fast chunk retrieval |\n| `chronicle_time` | `{ branchId: 1, cTime: -1 }` | Point-in-time queries |\n| `chronicle_latest` | `{ docId: 1, branchId: 1, isLatest: 1 }` | Current state queries (partial) |\n\n### Payload Indexes\n\nFor each indexed field in your schema, the plugin creates:\n- `chronicle_payload_{fieldName}` on `{ payload.{field}: 1, branchId: 1 }`\n- Partial filter: `{ isLatest: true, isDeleted: false }`\n\n### Keys Collection Indexes\n\n- `chronicle_keys_doc_branch` - Unique index on `{ docId: 1, branchId: 1 }`\n- `chronicle_keys_unique_{field}` - Unique index per unique field with partial filter\n\n## Best Practices\n\n1. **Choose fullChunkInterval wisely** - Lower values mean faster reads but more storage. Higher values save storage but slow down rehydration. Default of 10 is a good starting point.\n\n2. **Let the plugin detect indexes** - Unless you have specific needs, let the plugin auto-detect indexes from your schema rather than manually specifying them.\n\n3. **Initialize once** - Call `initializeChronicle()` once during application startup, not on every request.\n\n4. **Use branches for experiments** - Test changes on a branch before merging to main.\n\n5. **Consider unique constraints** - Be aware that unique constraints are enforced per-branch. A value can be unique within a branch but exist in multiple branches.\n\n6. **Monitor collection sizes** - Chronicle collections grow over time. Plan for increased storage requirements.\n\n7. **Use `chronicleSquash` sparingly** - Squash is destructive and irreversible. Always use `dryRun: true` first to preview what will be deleted. Consider using `chronicleRevert` instead if you only need to undo recent changes on a single branch.\n\n8. **Revert preserves branch independence** - When you revert a branch past a child branch's creation point, the child branch remains intact (branches are self-contained with their own FULL chunks). Only the `parentSerial` metadata is updated.\n\n## Current Limitations\n\nThe following features are planned but not yet fully implemented:\n\n- Branch merging - combining changes from one branch into another\n- `updateOne` / `updateMany` middleware - these query-based updates bypass chronicle tracking (use `findOneAndUpdate` or `doc.save()` instead)\n\n**Note:** All core CRUD operations are implemented. Point-in-time queries are available via `chronicleAsOf()` (single document) and `findAsOf()` (multi-document with filter). Document instance methods (`getHistory()`, `getBranches()`, `createSnapshot()`) are fully implemented.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbradfaas%2Fmongoose-chronicle","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbradfaas%2Fmongoose-chronicle","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbradfaas%2Fmongoose-chronicle/lists"}