because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(file-upload): extract NamedRegistry base into @cv/utils, TextExtractorRegistry extends it

TextExtractorRegistry was hand-rolling Map-backed register/unregister/get/getAll - the same pattern is duplicated in several other 'Registry' classes across the monorepo. Hoisted that scaffolding into a generic NamedRegistry<T extends Named> in @cv/utils so subclasses only declare the domain-specific lookups (findForMimeType, etc). Throws on duplicates with the subclass name in the error so future Registry subclasses get a sensible error out of the box. Added tests for the base behavior.

+158 -31
+1
packages/file-upload/package.json
··· 10 10 "test:watch": "vitest" 11 11 }, 12 12 "dependencies": { 13 + "@cv/utils": "*", 13 14 "@nestjs/common": "^11.1.3", 14 15 "canvas": "^3.1.0", 15 16 "mammoth": "^1.6.0",
+14 -31
packages/file-upload/src/extractor-registry.ts
··· 1 - import type { TextExtractor, TextExtractionResult } from './types'; 1 + import { NamedRegistry } from "@cv/utils"; 2 + import type { TextExtractor, TextExtractionResult } from "./types"; 2 3 3 4 /** 4 - * Registry for text extractors 5 - * Manages extractors and routes extraction requests to the appropriate handler 5 + * Routes file-extraction requests to the registered extractor that supports 6 + * the request's mime type. Uses NamedRegistry for the register/get/getAll 7 + * scaffolding; only the mime-type lookup is domain-specific. 6 8 */ 7 - export class TextExtractorRegistry { 8 - private extractors: Map<string, TextExtractor> = new Map(); 9 - 10 - register(extractor: TextExtractor): this { 11 - if (this.extractors.has(extractor.name)) { 12 - throw new Error(`Extractor '${extractor.name}' is already registered`); 13 - } 14 - this.extractors.set(extractor.name, extractor); 15 - return this; 16 - } 17 - 18 - unregister(name: string): boolean { 19 - return this.extractors.delete(name); 20 - } 21 - 22 - getAll(): TextExtractor[] { 23 - return Array.from(this.extractors.values()); 24 - } 25 - 26 - get(name: string): TextExtractor | undefined { 27 - return this.extractors.get(name); 28 - } 29 - 9 + export class TextExtractorRegistry extends NamedRegistry<TextExtractor> { 30 10 findForMimeType(mimeType: string): TextExtractor | undefined { 31 11 return this.getAll().find((extractor) => extractor.canHandle(mimeType)); 32 12 } 33 13 34 14 getSupportedMimeTypes(): string[] { 35 - return this.getAll().flatMap((extractor) => [...extractor.supportedMimeTypes]); 15 + return this.getAll().flatMap((extractor) => [ 16 + ...extractor.supportedMimeTypes, 17 + ]); 36 18 } 37 19 38 20 isSupported(mimeType: string): boolean { 39 21 return this.findForMimeType(mimeType) !== undefined; 40 22 } 41 23 42 - async extract(buffer: Buffer, mimeType: string): Promise<TextExtractionResult> { 24 + async extract( 25 + buffer: Buffer, 26 + mimeType: string, 27 + ): Promise<TextExtractionResult> { 43 28 const extractor = this.findForMimeType(mimeType); 44 - 45 29 if (!extractor) { 46 30 return { 47 31 success: false, 48 - error: `Unsupported file type: ${mimeType}. Supported types: ${this.getSupportedMimeTypes().join(', ')}`, 32 + error: `Unsupported file type: ${mimeType}. Supported types: ${this.getSupportedMimeTypes().join(", ")}`, 49 33 }; 50 34 } 51 - 52 35 return extractor.extract(buffer); 53 36 } 54 37 }
+83
packages/utils/__tests__/named-registry.test.ts
··· 1 + import assert from "node:assert/strict"; 2 + import { describe, it } from "node:test"; 3 + 4 + import { NamedRegistry } from "../index"; 5 + 6 + interface Plugin { 7 + readonly name: string; 8 + readonly value: number; 9 + } 10 + 11 + class PluginRegistry extends NamedRegistry<Plugin> { 12 + totalValue(): number { 13 + return this.getAll().reduce((sum, p) => sum + p.value, 0); 14 + } 15 + } 16 + 17 + describe("NamedRegistry", () => { 18 + it("registers and retrieves items by name", () => { 19 + const r = new PluginRegistry(); 20 + r.register({ name: "a", value: 1 }); 21 + r.register({ name: "b", value: 2 }); 22 + 23 + assert.deepStrictEqual(r.get("a"), { name: "a", value: 1 }); 24 + assert.deepStrictEqual(r.get("b"), { name: "b", value: 2 }); 25 + assert.strictEqual(r.size, 2); 26 + }); 27 + 28 + it("returns this from register for chaining", () => { 29 + const r = new PluginRegistry(); 30 + const ret = r.register({ name: "a", value: 1 }); 31 + assert.strictEqual(ret, r); 32 + }); 33 + 34 + it("throws on duplicate names with the subclass name in the message", () => { 35 + const r = new PluginRegistry(); 36 + r.register({ name: "a", value: 1 }); 37 + 38 + assert.throws(() => r.register({ name: "a", value: 99 }), { 39 + message: "'a' is already registered in PluginRegistry", 40 + }); 41 + }); 42 + 43 + it("returns undefined for unknown name", () => { 44 + const r = new PluginRegistry(); 45 + assert.strictEqual(r.get("missing"), undefined); 46 + }); 47 + 48 + it("has() reports presence", () => { 49 + const r = new PluginRegistry(); 50 + r.register({ name: "a", value: 1 }); 51 + assert.strictEqual(r.has("a"), true); 52 + assert.strictEqual(r.has("b"), false); 53 + }); 54 + 55 + it("unregister removes and returns true; false when missing", () => { 56 + const r = new PluginRegistry(); 57 + r.register({ name: "a", value: 1 }); 58 + 59 + assert.strictEqual(r.unregister("a"), true); 60 + assert.strictEqual(r.has("a"), false); 61 + assert.strictEqual(r.unregister("a"), false); 62 + }); 63 + 64 + it("getAll returns insertion order", () => { 65 + const r = new PluginRegistry(); 66 + r.register({ name: "z", value: 1 }); 67 + r.register({ name: "a", value: 2 }); 68 + r.register({ name: "m", value: 3 }); 69 + 70 + assert.deepStrictEqual( 71 + r.getAll().map((p) => p.name), 72 + ["z", "a", "m"], 73 + ); 74 + }); 75 + 76 + it("supports subclass-specific methods", () => { 77 + const r = new PluginRegistry(); 78 + r.register({ name: "a", value: 5 }); 79 + r.register({ name: "b", value: 7 }); 80 + 81 + assert.strictEqual(r.totalValue(), 12); 82 + }); 83 + });
+57
packages/utils/index.ts
··· 88 88 Object.fromEntries( 89 89 Object.entries(obj).filter(([, v]) => v != null), 90 90 ) as never; 91 + 92 + /** 93 + * An object with a stable string identifier — the contract a `NamedRegistry` 94 + * uses to key its entries. 95 + */ 96 + export interface Named { 97 + readonly name: string; 98 + } 99 + 100 + /** 101 + * Map-backed registry of `Named` items. Subclass to add domain-specific 102 + * lookup methods (`findForMimeType`, `findByCapability`, etc.) without 103 + * re-implementing register/unregister/get/getAll. 104 + * 105 + * Throws on duplicate names so a misconfigured wiring fails loudly at 106 + * startup rather than silently overwriting an earlier registration. 107 + * 108 + * @example 109 + * ```typescript 110 + * class TextExtractorRegistry extends NamedRegistry<TextExtractor> { 111 + * findForMimeType(mimeType: string): TextExtractor | undefined { 112 + * return this.getAll().find((e) => e.canHandle(mimeType)); 113 + * } 114 + * } 115 + * ``` 116 + */ 117 + export class NamedRegistry<T extends Named> { 118 + protected readonly items = new Map<string, T>(); 119 + 120 + register(item: T): this { 121 + if (this.items.has(item.name)) { 122 + raise(`'${item.name}' is already registered in ${this.constructor.name}`); 123 + } 124 + this.items.set(item.name, item); 125 + return this; 126 + } 127 + 128 + unregister(name: string): boolean { 129 + return this.items.delete(name); 130 + } 131 + 132 + get(name: string): T | undefined { 133 + return this.items.get(name); 134 + } 135 + 136 + has(name: string): boolean { 137 + return this.items.has(name); 138 + } 139 + 140 + getAll(): T[] { 141 + return Array.from(this.items.values()); 142 + } 143 + 144 + get size(): number { 145 + return this.items.size; 146 + } 147 + }
+3
pnpm-lock.yaml
··· 888 888 889 889 packages/file-upload: 890 890 dependencies: 891 + '@cv/utils': 892 + specifier: '*' 893 + version: link:../utils 891 894 '@nestjs/common': 892 895 specifier: ^11.1.3 893 896 version: 11.1.11(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)