diff --git a/packages/backend-flags/package.json b/packages/backend-flags/package.json new file mode 100644 index 00000000..1427a68a --- /dev/null +++ b/packages/backend-flags/package.json @@ -0,0 +1,27 @@ +{ + "name": "@bytelyst/backend-flags", + "version": "0.1.0", + "description": "In-memory feature flag registry for Fastify product backends", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "files": [ + "dist" + ] +} diff --git a/packages/backend-flags/src/index.test.ts b/packages/backend-flags/src/index.test.ts new file mode 100644 index 00000000..fa5aba5f --- /dev/null +++ b/packages/backend-flags/src/index.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { createFlagRegistry } from './index.js'; + +describe('createFlagRegistry', () => { + it('returns default flag values', () => { + const registry = createFlagRegistry({ + defaults: { 'feature.a': true, 'feature.b': false }, + }); + expect(registry.isFeatureEnabled('feature.a')).toBe(true); + expect(registry.isFeatureEnabled('feature.b')).toBe(false); + }); + + it('returns false for unknown flags', () => { + const registry = createFlagRegistry({ defaults: {} }); + expect(registry.isFeatureEnabled('nonexistent')).toBe(false); + }); + + it('getAllFlags returns all defaults', () => { + const registry = createFlagRegistry({ + defaults: { a: true, b: false, c: true }, + }); + expect(registry.getAllFlags()).toEqual({ a: true, b: false, c: true }); + }); + + it('setFlag overrides a value', () => { + const registry = createFlagRegistry({ defaults: { x: false } }); + expect(registry.isFeatureEnabled('x')).toBe(false); + registry.setFlag('x', true); + expect(registry.isFeatureEnabled('x')).toBe(true); + }); + + it('setFlag creates new flags', () => { + const registry = createFlagRegistry({ defaults: {} }); + registry.setFlag('new.flag', true); + expect(registry.isFeatureEnabled('new.flag')).toBe(true); + expect(registry.getAllFlags()).toEqual({ 'new.flag': true }); + }); + + it('accepts userId parameter without error', () => { + const registry = createFlagRegistry({ defaults: { a: true } }); + expect(registry.isFeatureEnabled('a', 'user-1')).toBe(true); + }); +}); diff --git a/packages/backend-flags/src/index.ts b/packages/backend-flags/src/index.ts new file mode 100644 index 00000000..30ad476b --- /dev/null +++ b/packages/backend-flags/src/index.ts @@ -0,0 +1,38 @@ +/** + * In-memory feature flag registry for product backends. + * + * Products call createFlagRegistry() with their default flags, + * then use isFeatureEnabled/getAllFlags/setFlag as needed. + */ + +export interface FlagRegistry { + isFeatureEnabled(flag: string, userId?: string): boolean; + getAllFlags(): Record; + setFlag(flag: string, value: boolean): void; +} + +export interface FlagRegistryOptions { + /** Default flag values. */ + defaults: Record; + /** Master switch — when false, flags are still resolved from defaults but + * the registry won't attempt remote/dynamic flag resolution (future use). */ + enabled?: boolean; +} + +export function createFlagRegistry(opts: FlagRegistryOptions): FlagRegistry { + const flags: Map = new Map(Object.entries(opts.defaults)); + + return { + isFeatureEnabled(flag: string, _userId?: string): boolean { + return flags.get(flag) ?? false; + }, + + getAllFlags(): Record { + return Object.fromEntries(flags); + }, + + setFlag(flag: string, value: boolean): void { + flags.set(flag, value); + }, + }; +} diff --git a/packages/backend-flags/tsconfig.json b/packages/backend-flags/tsconfig.json new file mode 100644 index 00000000..01c4d9a3 --- /dev/null +++ b/packages/backend-flags/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src"] +} diff --git a/packages/backend-telemetry/package.json b/packages/backend-telemetry/package.json new file mode 100644 index 00000000..6b64d26c --- /dev/null +++ b/packages/backend-telemetry/package.json @@ -0,0 +1,27 @@ +{ + "name": "@bytelyst/backend-telemetry", + "version": "0.1.0", + "description": "In-memory telemetry event buffer for Fastify product backends", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "files": [ + "dist" + ] +} diff --git a/packages/backend-telemetry/src/index.test.ts b/packages/backend-telemetry/src/index.test.ts new file mode 100644 index 00000000..ad15d9e3 --- /dev/null +++ b/packages/backend-telemetry/src/index.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { createTelemetryBuffer } from './index.js'; + +describe('createTelemetryBuffer', () => { + it('buffers events when enabled', () => { + const buf = createTelemetryBuffer({ enabled: true }); + buf.trackEvent('test.event', 'user-1', { key: 'val' }); + const events = buf.getBufferedEvents(); + expect(events).toHaveLength(1); + expect(events[0].event).toBe('test.event'); + expect(events[0].userId).toBe('user-1'); + expect(events[0].properties).toEqual({ key: 'val' }); + expect(events[0].timestamp).toBeDefined(); + }); + + it('is a no-op when disabled', () => { + const buf = createTelemetryBuffer({ enabled: false }); + buf.trackEvent('test.event', 'user-1'); + expect(buf.getBufferedEvents()).toHaveLength(0); + }); + + it('flushEvents returns and clears buffer', () => { + const buf = createTelemetryBuffer({ enabled: true }); + buf.trackEvent('a'); + buf.trackEvent('b'); + const flushed = buf.flushEvents(); + expect(flushed).toHaveLength(2); + expect(buf.getBufferedEvents()).toHaveLength(0); + }); + + it('getBufferedEvents returns a copy', () => { + const buf = createTelemetryBuffer({ enabled: true }); + buf.trackEvent('a'); + const copy = buf.getBufferedEvents(); + copy.push({ event: 'fake' }); + expect(buf.getBufferedEvents()).toHaveLength(1); + }); + + it('handles missing optional fields', () => { + const buf = createTelemetryBuffer({ enabled: true }); + buf.trackEvent('minimal'); + const events = buf.getBufferedEvents(); + expect(events[0].userId).toBeUndefined(); + expect(events[0].properties).toBeUndefined(); + }); +}); diff --git a/packages/backend-telemetry/src/index.ts b/packages/backend-telemetry/src/index.ts new file mode 100644 index 00000000..ffd9cfaf --- /dev/null +++ b/packages/backend-telemetry/src/index.ts @@ -0,0 +1,50 @@ +/** + * In-memory telemetry event buffer for product backends. + * + * Products call createTelemetryBuffer() with an enabled flag, + * then use trackEvent/getBufferedEvents/flushEvents as needed. + */ + +export interface TelemetryEvent { + event: string; + userId?: string; + properties?: Record; + timestamp?: string; +} + +export interface TelemetryBuffer { + trackEvent(event: string, userId?: string, properties?: Record): void; + getBufferedEvents(): TelemetryEvent[]; + flushEvents(): TelemetryEvent[]; +} + +export interface TelemetryBufferOptions { + /** Master switch — when false, trackEvent is a no-op. */ + enabled: boolean; +} + +export function createTelemetryBuffer(opts: TelemetryBufferOptions): TelemetryBuffer { + const buffer: TelemetryEvent[] = []; + + return { + trackEvent(event: string, userId?: string, properties?: Record): void { + if (!opts.enabled) return; + buffer.push({ + event, + userId, + properties, + timestamp: new Date().toISOString(), + }); + }, + + getBufferedEvents(): TelemetryEvent[] { + return [...buffer]; + }, + + flushEvents(): TelemetryEvent[] { + const flushed = [...buffer]; + buffer.length = 0; + return flushed; + }, + }; +} diff --git a/packages/backend-telemetry/tsconfig.json b/packages/backend-telemetry/tsconfig.json new file mode 100644 index 00000000..01c4d9a3 --- /dev/null +++ b/packages/backend-telemetry/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ace17135..708f8573 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -363,6 +363,24 @@ importers: specifier: ^3.0.5 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/backend-flags: + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + + packages/backend-telemetry: + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/blob: dependencies: '@bytelyst/storage':