diff --git a/changelog.md b/changelog.md index a4622e1..18d6301 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-02-19 - 3.2.0 - feat(destination-buffer) +add SmartlogDestinationBuffer in-memory circular buffer destination with query/filter/pagination and tests + +- Introduce SmartlogDestinationBuffer: an in-memory circular buffer implementing ILogDestination with configurable maxEntries (default 2000). +- Expose APIs: handleLog, getEntries (supports level filtering, search, since timestamp, limit/offset pagination, newest-first ordering), getEntryCount, and clear. +- Add package export './destination-buffer' and module entry points (index and plugins) to expose the new destination. +- Add tests covering storage, filtering by level and search, pagination, eviction when maxEntries exceeded, clearing, and default maxEntries behavior. + ## 2026-02-14 - 3.1.11 - fix(destination-receiver) return full webrequest response from SmartlogDestinationReceiver and migrate to WebrequestClient; update tests, dependencies, docs, and npmextra metadata diff --git a/package.json b/package.json index 14dc245..98f0795 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "./destination-devtools": "./dist_ts_destination_devtools/index.js", "./destination-file": "./dist_ts_destination_file/index.js", "./destination-local": "./dist_ts_destination_local/index.js", + "./destination-buffer": "./dist_ts_destination_buffer/index.js", "./destination-receiver": "./dist_ts_destination_receiver/index.js", "./receiver": "./dist_ts_receiver/index.js" }, diff --git a/test/test.destination-buffer.node.ts b/test/test.destination-buffer.node.ts new file mode 100644 index 0000000..1723345 --- /dev/null +++ b/test/test.destination-buffer.node.ts @@ -0,0 +1,117 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { SmartlogDestinationBuffer } from '../ts_destination_buffer/index.js'; +import type { ILogPackage, TLogLevel } from '../ts_interfaces/index.js'; + +const createMockLogPackage = (level: TLogLevel, message: string): ILogPackage => { + return { + timestamp: Date.now(), + type: 'log', + level, + message, + context: { + environment: 'test', + runtime: 'node', + zone: 'test-zone', + }, + correlation: { + id: '123', + type: 'none', + }, + }; +}; + +let buffer: SmartlogDestinationBuffer; + +tap.test('should create a buffer destination instance', async () => { + buffer = new SmartlogDestinationBuffer({ maxEntries: 100 }); + expect(buffer).toBeTruthy(); + expect(buffer.getEntryCount()).toEqual(0); +}); + +tap.test('should store log entries via handleLog', async () => { + await buffer.handleLog(createMockLogPackage('info', 'Hello world')); + await buffer.handleLog(createMockLogPackage('error', 'Something failed')); + await buffer.handleLog(createMockLogPackage('warn', 'Watch out')); + + expect(buffer.getEntryCount()).toEqual(3); +}); + +tap.test('should retrieve entries newest-first', async () => { + const entries = buffer.getEntries(); + expect(entries.length).toEqual(3); + expect(entries[0].message).toEqual('Watch out'); + expect(entries[2].message).toEqual('Hello world'); +}); + +tap.test('should filter entries by level', async () => { + const errorEntries = buffer.getEntries({ level: 'error' }); + expect(errorEntries.length).toEqual(1); + expect(errorEntries[0].message).toEqual('Something failed'); + + const multiLevel = buffer.getEntries({ level: ['info', 'warn'] }); + expect(multiLevel.length).toEqual(2); +}); + +tap.test('should filter entries by search string', async () => { + const results = buffer.getEntries({ search: 'hello' }); + expect(results.length).toEqual(1); + expect(results[0].message).toEqual('Hello world'); +}); + +tap.test('should support limit and offset pagination', async () => { + const page1 = buffer.getEntries({ limit: 2, offset: 0 }); + expect(page1.length).toEqual(2); + expect(page1[0].message).toEqual('Watch out'); + + const page2 = buffer.getEntries({ limit: 2, offset: 2 }); + expect(page2.length).toEqual(1); + expect(page2[0].message).toEqual('Hello world'); +}); + +tap.test('should filter by since timestamp', async () => { + const now = Date.now(); + const freshBuffer = new SmartlogDestinationBuffer(); + + const oldPkg = createMockLogPackage('info', 'Old message'); + oldPkg.timestamp = now - 60000; + await freshBuffer.handleLog(oldPkg); + + const newPkg = createMockLogPackage('info', 'New message'); + newPkg.timestamp = now; + await freshBuffer.handleLog(newPkg); + + const results = freshBuffer.getEntries({ since: now - 1000 }); + expect(results.length).toEqual(1); + expect(results[0].message).toEqual('New message'); +}); + +tap.test('should enforce circular buffer max entries', async () => { + const smallBuffer = new SmartlogDestinationBuffer({ maxEntries: 5 }); + + for (let i = 0; i < 10; i++) { + await smallBuffer.handleLog(createMockLogPackage('info', `Message ${i}`)); + } + + expect(smallBuffer.getEntryCount()).toEqual(5); + + // Should have kept the latest 5 (messages 5-9) + const entries = smallBuffer.getEntries({ limit: 10 }); + expect(entries[0].message).toEqual('Message 9'); + expect(entries[4].message).toEqual('Message 5'); +}); + +tap.test('should clear all entries', async () => { + expect(buffer.getEntryCount()).toBeGreaterThan(0); + buffer.clear(); + expect(buffer.getEntryCount()).toEqual(0); + expect(buffer.getEntries().length).toEqual(0); +}); + +tap.test('should use default maxEntries of 2000', async () => { + const defaultBuffer = new SmartlogDestinationBuffer(); + // Just verify it was created without error + expect(defaultBuffer).toBeTruthy(); + expect(defaultBuffer.getEntryCount()).toEqual(0); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e1ec593..15de5cf 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartlog', - version: '3.1.11', + version: '3.2.0', description: 'A minimalistic, distributed, and extensible logging tool supporting centralized log management.' } diff --git a/ts_destination_buffer/classes.destinationbuffer.ts b/ts_destination_buffer/classes.destinationbuffer.ts new file mode 100644 index 0000000..7c83d85 --- /dev/null +++ b/ts_destination_buffer/classes.destinationbuffer.ts @@ -0,0 +1,67 @@ +import type { ILogDestination, ILogPackage, TLogLevel } from '../dist_ts_interfaces/index.js'; + +export interface IDestinationBufferOptions { + maxEntries?: number; +} + +export interface IBufferQueryOptions { + level?: TLogLevel | TLogLevel[]; + search?: string; + limit?: number; + offset?: number; + since?: number; +} + +export class SmartlogDestinationBuffer implements ILogDestination { + private logPackages: ILogPackage[] = []; + private maxEntries: number; + + constructor(options?: IDestinationBufferOptions) { + this.maxEntries = options?.maxEntries ?? 2000; + } + + public async handleLog(logPackage: ILogPackage): Promise { + this.logPackages.push(logPackage); + if (this.logPackages.length > this.maxEntries) { + this.logPackages.shift(); + } + } + + public getEntries(options?: IBufferQueryOptions): ILogPackage[] { + const limit = options?.limit ?? 100; + const offset = options?.offset ?? 0; + + let results = this.logPackages; + + // Filter by level + if (options?.level) { + const levels = Array.isArray(options.level) ? options.level : [options.level]; + results = results.filter((pkg) => levels.includes(pkg.level)); + } + + // Filter by search (message content) + if (options?.search) { + const searchLower = options.search.toLowerCase(); + results = results.filter((pkg) => pkg.message.toLowerCase().includes(searchLower)); + } + + // Filter by timestamp + if (options?.since) { + results = results.filter((pkg) => pkg.timestamp >= options.since); + } + + // Return newest-first, with pagination + return results + .slice() + .reverse() + .slice(offset, offset + limit); + } + + public getEntryCount(): number { + return this.logPackages.length; + } + + public clear(): void { + this.logPackages = []; + } +} diff --git a/ts_destination_buffer/index.ts b/ts_destination_buffer/index.ts new file mode 100644 index 0000000..5a30255 --- /dev/null +++ b/ts_destination_buffer/index.ts @@ -0,0 +1 @@ +export { SmartlogDestinationBuffer, type IDestinationBufferOptions, type IBufferQueryOptions } from './classes.destinationbuffer.js'; diff --git a/ts_destination_buffer/plugins.ts b/ts_destination_buffer/plugins.ts new file mode 100644 index 0000000..7965b01 --- /dev/null +++ b/ts_destination_buffer/plugins.ts @@ -0,0 +1,3 @@ +import * as smartlogInterfaces from '../dist_ts_interfaces/index.js'; + +export { smartlogInterfaces };